From c23f2334463e2be361a04fcde8bc40b371f832fc Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Fri, 30 Oct 2026 14:08:17 +0300 Subject: [PATCH 01/10] M8: a font's damaged table offsets are skipped instead of wrapping on 32-bit targets (#1125) normal_track read the trak size table at a file-controlled u32 offset with unchecked additions, which wrap on 32-bit targets such as wasm32 (a debug panic, or reads at the wrong offset). Its reads are now checked, and an incomplete normal track is dropped entirely, never returned in part. index_bytes was already bounded by fill(), which checks each range before the flagged additions run; this makes those bounds explicit, still skipping damaged faces and reading later faces' name tables without their outlines. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Fable 5.1 Co-Authored-By: GPT-6.1 Sol Co-Authored-By: GPT-6 Astra --- ROADMAP.md | 5 +- crates/text/src/fontdb.rs | 28 +++++---- crates/text/src/fontdb_tests.rs | 101 ++++++++++++++++++++++++++++++++ docs/roadmap.md | 5 +- 4 files changed, 123 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d6540ba9..adde678d 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-30 · **Change:** minor (a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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 @@ -60,7 +60,7 @@ native speakers' reviews of the nine language drafts need people. | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | File formats | 60% (`.fig` read best effort with editable vectors and a total ZIP image byte budget; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) | | Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins and publishes to a server, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) | -| Stability | 55% | 20–40 | [gaps](docs/gaps.md) | +| Stability | 55% (damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, #1125) | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | 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); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) | @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-30**: M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets, and an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, preserving later faces and sparse name-table reads. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged. - **2026-10-29** · M10: correct nine German labels in `de.toml`: gradient stops use Verlaufsstopp, OpenType ss01–ss05 use Stilsatz, and the Home-screen preference names the Startbildschirm consistently in the UI and Actions palette. This is a partial terminology pass; German remains a draft awaiting a full native-speaker review and maintainer approval. Numbers unchanged. - **2026-10-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged. - **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged. diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index d64c6774..9f2dfe1f 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -481,11 +481,12 @@ fn name_string(f: &skrifa::FontRef, id: StringId) -> Option { } /// The normal (0) horizontal track of a `trak` table: (size, tracking in font units) by size. Empty -/// when the table is missing, damaged or has no normal track. +/// when the table is missing, damaged or has no normal track. A short size or value array invalidates +/// the entire track. fn normal_track(t: &[u8]) -> Vec<(f64, f64)> { - let u16_at = |o: usize| t.get(o..o + 2).map(|b| u16::from_be_bytes([b[0], b[1]])); - let i16_at = |o: usize| t.get(o..o + 2).map(|b| i16::from_be_bytes([b[0], b[1]])); - let fixed_at = |o: usize| t.get(o..o + 4).map(|b| f64::from(i32::from_be_bytes([b[0], b[1], b[2], b[3]])) / 65536.0); + let u16_at = |o: usize| t.get(o..o.checked_add(2)?).map(|b| u16::from_be_bytes([b[0], b[1]])); + let i16_at = |o: usize| t.get(o..o.checked_add(2)?).map(|b| i16::from_be_bytes([b[0], b[1]])); + let fixed_at = |o: usize| t.get(o..o.checked_add(4)?).map(|b| f64::from(i32::from_be_bytes([b[0], b[1], b[2], b[3]])) / 65536.0); let Some(horiz) = u16_at(6).map(usize::from).filter(|o| *o > 0) else { return Vec::new() }; let (Some(tracks), Some(sizes)) = (u16_at(horiz).map(usize::from), u16_at(horiz + 2).map(usize::from)) else { return Vec::new() }; let Some(size_table) = t.get(horiz + 4..horiz + 8).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize) else { return Vec::new() }; @@ -497,10 +498,10 @@ fn normal_track(t: &[u8]) -> Vec<(f64, f64)> { } let Some(values) = u16_at(entry + 6).map(usize::from) else { return Vec::new() }; let out: Vec<(f64, f64)> = (0..sizes) - .filter_map(|i| Some((fixed_at(size_table + i * 4)?, f64::from(i16_at(values + i * 2)?)))) + .filter_map(|i| Some((fixed_at(size_table.checked_add(i.checked_mul(4)?)?)?, f64::from(i16_at(values.checked_add(i * 2)?)?)))) .filter(|(s, _)| s.is_finite()) .collect(); - return if out.windows(2).all(|w| w[0].0 < w[1].0) { out } else { Vec::new() }; + return if out.len() == sizes && out.windows(2).all(|w| w[0].0 < w[1].0) { out } else { Vec::new() }; } Vec::new() } @@ -1321,14 +1322,17 @@ fn index_bytes(path: &std::path::Path, len: usize) -> Option> { } else { vec![0] }; - for at in fonts { - let Some(tables) = fill(&mut buf, at, 12).and_then(|()| be(&buf, at + 4, 2)) else { continue }; - if fill(&mut buf, at + 12, 16 * tables).is_none() { + 'faces: for at in fonts { + let Some(tables) = fill(&mut buf, at, 12).and_then(|()| be(&buf, at.checked_add(4)?, 2)) else { continue }; + let Some(dir) = at.checked_add(12) else { continue }; + if fill(&mut buf, dir, 16 * tables).is_none() { continue; } - for rec in (0..tables).map(|t| at + 12 + 16 * t) { - if buf.get(rec..rec + 4) == Some(b"name") - && let (Some(off), Some(n)) = (be(&buf, rec + 8, 4), be(&buf, rec + 12, 4)) + for t in 0..tables { + let Some(rec) = dir.checked_add(16 * t) else { continue 'faces }; + let (Some(tag_end), Some(off_at), Some(len_at)) = (rec.checked_add(4), rec.checked_add(8), rec.checked_add(12)) else { continue 'faces }; + if buf.get(rec..tag_end) == Some(b"name") + && let (Some(off), Some(n)) = (be(&buf, off_at, 4), be(&buf, len_at, 4)) { let _ = fill(&mut buf, off, n); } diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 7e7222c3..dc75b13c 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -73,6 +73,62 @@ fn the_scan_reads_names_like_a_whole_file_read() { } } +fn indexed_test_font(bytes: &[u8], case: &str) -> Vec { + struct TempFile(std::path::PathBuf); + impl Drop for TempFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + let file = TempFile(std::env::temp_dir().join(format!("figcraft-font-index-{case}-{}.ttc", std::process::id()))); + std::fs::write(&file.0, bytes).unwrap(); + index_bytes(&file.0, bytes.len()).unwrap() +} + +fn synthetic_font_collection(first: u32) -> Vec { + let mut bytes = vec![0; 112]; + bytes[..4].copy_from_slice(b"ttcf"); + bytes[4..8].copy_from_slice(&0x0001_0000u32.to_be_bytes()); + bytes[8..12].copy_from_slice(&2u32.to_be_bytes()); + bytes[12..16].copy_from_slice(&first.to_be_bytes()); + bytes[16..20].copy_from_slice(&32u32.to_be_bytes()); + // The second face has a name table and an outline table. Only names should be read. + bytes[32..36].copy_from_slice(&0x0001_0000u32.to_be_bytes()); + bytes[36..38].copy_from_slice(&2u16.to_be_bytes()); + bytes[44..48].copy_from_slice(b"name"); + bytes[52..56].copy_from_slice(&80u32.to_be_bytes()); + bytes[56..60].copy_from_slice(&8u32.to_be_bytes()); + bytes[60..64].copy_from_slice(b"glyf"); + bytes[68..72].copy_from_slice(&96u32.to_be_bytes()); + bytes[72..76].copy_from_slice(&4u32.to_be_bytes()); + bytes[80..88].copy_from_slice(b"fontname"); + bytes[96..100].copy_from_slice(b"skip"); + bytes +} + +#[test] +fn index_bytes_skips_collection_offsets_near_u32_max() { + for offset in [u32::MAX - 11, u32::MAX - 1, u32::MAX] { + let bytes = synthetic_font_collection(offset); + let indexed = indexed_test_font(&bytes, "offset"); + assert_eq!(&indexed[32..76], &bytes[32..76]); + assert_eq!(&indexed[80..88], b"fontname"); + assert_eq!(&indexed[96..100], &[0; 4], "outline bytes stay unread"); + } +} + +#[test] +fn index_bytes_skips_a_truncated_directory_and_reads_the_next_face() { + let mut bytes = synthetic_font_collection(100); + bytes[100..104].copy_from_slice(&0x0001_0000u32.to_be_bytes()); + bytes[104..106].copy_from_slice(&2u16.to_be_bytes()); + let indexed = indexed_test_font(&bytes, "directory"); + assert_eq!(&indexed[100..112], &bytes[100..112], "the header fits but its directory does not"); + assert_eq!(&indexed[32..76], &bytes[32..76]); + assert_eq!(&indexed[80..88], b"fontname"); + assert_eq!(&indexed[96..100], &[0; 4], "outline bytes stay unread"); +} + #[test] fn macos_font_assets_are_found_in_the_asset_store() { // A store laid out like macOS's: fonts downloaded on demand sit in `.asset/AssetData` @@ -403,6 +459,51 @@ fn named_styles_keep_their_optical_size() { assert_eq!(face.axis_values(&style)[opsz], face.axes[opsz].max); } +fn synthetic_trak() -> Vec { + let mut bytes = vec![0; 46]; + bytes[..4].copy_from_slice(&0x0001_0000u32.to_be_bytes()); + bytes[6..8].copy_from_slice(&12u16.to_be_bytes()); + bytes[12..14].copy_from_slice(&1u16.to_be_bytes()); + bytes[14..16].copy_from_slice(&3u16.to_be_bytes()); + bytes[16..20].copy_from_slice(&28u32.to_be_bytes()); + // The normal track (0.0), with three sizes and their signed tracking values. + bytes[24..26].copy_from_slice(&256u16.to_be_bytes()); + bytes[26..28].copy_from_slice(&40u16.to_be_bytes()); + for (i, size) in [10i32, 20, 40].into_iter().enumerate() { + bytes[28 + i * 4..32 + i * 4].copy_from_slice(&(size * 65536).to_be_bytes()); + } + for (i, value) in [-8i16, 0, 16].into_iter().enumerate() { + bytes[40 + i * 2..42 + i * 2].copy_from_slice(&value.to_be_bytes()); + } + bytes +} + +#[test] +fn normal_track_reads_a_well_formed_table() { + assert_eq!(normal_track(&synthetic_trak()), [(10.0, -8.0), (20.0, 0.0), (40.0, 16.0)]); +} + +#[test] +fn normal_track_ignores_size_table_offsets_near_u32_max() { + let mut bytes = synthetic_trak(); + // On 32-bit targets even the first size's end can overflow. On 64-bit these are out of bounds. + for offset in [u32::MAX - 7, u32::MAX - 3, u32::MAX - 1, u32::MAX] { + bytes[16..20].copy_from_slice(&offset.to_be_bytes()); + assert!(normal_track(&bytes).is_empty(), "size table at {offset}"); + } +} + +#[test] +fn normal_track_never_returns_a_partial_track_from_a_damaged_table() { + let mut bytes = synthetic_trak(); + bytes.truncate(44); // Two values fit, but the third is missing. + assert!(normal_track(&bytes).is_empty()); + let mut bytes = synthetic_trak(); + bytes[26..28].copy_from_slice(&20u16.to_be_bytes()); + bytes.truncate(36); // All values fit, but only two sizes do. + assert!(normal_track(&bytes).is_empty()); +} + /// The tracking a font's `trak` table gives at a size (Apple's fonts carry one; the platform text /// engine applies it on its own), in pixels, interpolated between the sizes the table lists. #[test] diff --git a/docs/roadmap.md b/docs/roadmap.md index b5279d69..caa2f238 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-30 · **Change:** minor (a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); 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 @@ -57,7 +57,7 @@ blockers. | M5 Prototype & present | ~85% | media, controllers on macOS/Linux/FreeBSD, separate window | 2–5 | | M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11; best-effort `.sketch` import 2026-10-21) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 | | M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 | -| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% (`.fig` ZIP image reads bounded in total, #1076) (explicit variables files capped at 256 MiB 2026-10-30, part of #1114) | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 | +| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% (damaged font tracking table offsets are checked instead of wrapping on 32-bit targets and existing font index bounds made explicit, #1125; `.fig` ZIP image reads bounded in total, #1076) (explicit variables files capped at 256 MiB 2026-10-30, part of #1114) | 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; the server key compared by its hash (#1130) | 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; Arabic draft: 2,508 entries, native review and panel mirroring pending | native reviews (including the Arabic draft), Hindi, Vietnamese, Arabic panel mirroring, 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); 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 | @@ -76,6 +76,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets; an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, still skipping damaged faces and reading later faces' name tables without reading outlines. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged. | | 2026-10-30 | minor | M3, #1613 (after #1618): the rest of the stored-id work. `Variables::get` and `Variables::collection` take the variable or collection with that exact id before one named like it (as `find_mode` does), so typed ids in `variable.bind`, `variable.update`, aliases and the prototype action and expression input can't be taken by a renamed one. Stored mode ids go through the new `Collection::mode_by_id` (expression evaluation, display and type checks, the prototype's mode switch), font family variables read their stored ids exactly, and the colour picker, prototype editor and panels' labels (variables, prototype, selection colours, typography, style picker, assets, variable fields) read exact ids. Tests: `stored_ids_tests.rs` (exported aliases, deletion, Dev Inspect, expressions, mode switch, library import, interaction edits) and the doc crate's lookup test; #1618's expression test now expects the exact id to win. | | 2026-10-30 | minor | M6, gap 6: widget hover styles (`hover` on a widget layer: fill, stroke, opacity, text colour) shown while the pointer is over it, as a live preview; `widget.hover`. Tests: `widget_hover_tests` | | 2026-10-30 | minor | M6, gap 1: `.fig` elbowed and curved connectors without a saved path are routed like board connectors (shared `figcraft_doc::route_points`) instead of drawn straight. Tests: `fig::tests::tail` | From 27920c5e2639bf79d5deecf89ab2d6640c0760b0 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 20:53:03 +0700 Subject: [PATCH 02/10] M8: a drag moving many layers normalises only what sits around them, not each layer's bindings, sizing, text and instance checks (#1571) (#1656) --- ROADMAP.md | 3 +- crates/engine/src/derived.rs | 47 +++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/move_shortcut_tests.rs | 142 +++++++++++++++++++++++ docs/roadmap.md | 3 +- 5 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/move_shortcut_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 6059b437..a17e0919 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-30 · **Change:** minor (damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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 @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1571 (part 4): every move of a drag copies the document from where the drag started and normalises it. The copy was already cheap, since layers are shared, not copied (about 0 ms). Normalising took 1.4 ms per move with 648 layers dragged, because each moved layer went through every step: variable bindings and style links, sizing coherence, text auto-size and the instance sync. Now `normalize` sends a layer whose transform is all that changed, and which is in no component or instance, only through the steps that follow what sits around it: groups refitting, auto layout, connectors, ready-for-dev marks and comment pins. Its bindings (never a position), style links, sizing and text have nothing to follow. This applies to any edit that only moves layers, not just drags. Normalising those 648 moves now takes 0.78 ms. Tests compare the result with a full normalisation for every layer, the top-level layers and every third layer of each example file moved at once, and for a drag of plain, bound, grouped, absolutely placed and component layers, while dragging and after the drop. | | 2026-10-30 | minor | M10: Bosnian (`bs`, Bosanski) draft catalog: all 1,901 UI strings, 629 command labels and 31 menu wordings, machine-drafted in Latin script (ijekavian) and waiting for a native speaker's review. Bosnian's integer plural rule (one for 1, 21, 31… except 11; few for 2–4, 22–24… except 12–14; other otherwise) in `i18n::plural`. Numbers unchanged. Tests: `bosnian_tags_and_integer_plurals`. | | 2026-10-30 | minor | Text style dedupe is O(spans) (M8, #1102): `shape_text`'s scan over collected styles replaced by a last-two cache and fingerprint buckets resolved with the full equality — same result, O(spans) for the styles that usually differ, never worse than the scan; the font-db write lock untouched (face loading needs it). `layout_dedupe_tests`. Numbers unchanged | | 2026-10-30 | minor | M6, #1646: a layer pasted from another design app whose image the clipboard didn't carry came in flat grey, like a grey the design chose, and the paste said only that some kinds of content were left out. Now the placeholder an import stores for an image it doesn't have is light grey crossed by darker diagonal stripes (REST JSON, `.fig` and paste alike), the fill keeps the image's own name, the `.fig` and paste note is a fixed, translated one per image ("image fills whose image isn't in the file show a missing-image placeholder and keep its name"), `edit.pasteDesignLayers` returns `missingImages` (the names of the pasted layers concerned), the paste's message names them, and a paste with notes opens the import report like an import. Tests: `fig/missing_image_tests.rs`, `paste_missing_image_tests.rs`, `import_report_tests.rs`. | diff --git a/crates/engine/src/derived.rs b/crates/engine/src/derived.rs index 26b927f6..3867ea71 100644 --- a/crates/engine/src/derived.rs +++ b/crates/engine/src/derived.rs @@ -39,9 +39,13 @@ pub fn normalize(doc: &mut Document, before: &Document) { return; } let m = measure::TextMeasure; + let restyled = doc.variables != before.variables || doc.styles != before.styles; + // Layers that only moved join from step 3 on: what sits around them may follow, they don't. + let moved = if restyled { BTreeSet::new() } else { only_moved_layers(doc, before, &touched) }; + touched.retain(|id| !moved.contains(id)); // 0. Variable bindings and style links feed node values. let scope = binding_scope(doc, before, &touched); - if doc.variables != before.variables || doc.styles != before.styles { + if restyled { style_vars::settle(doc); } cmd::styles::apply_bindings_to(doc, scope.as_ref()); @@ -93,6 +97,7 @@ pub fn normalize(doc: &mut Document, before: &Document) { touched.extend(synced); } touched.extend(doc.take_dirty()); + touched.extend(moved); // Parents of removed nodes (as they were before) count as touched. let removed_parents: Vec = touched.iter().filter(|i| !doc.contains(**i)).filter_map(|i| before.node(*i).and_then(|n| n.parent)).filter(|p| doc.contains(*p)).collect(); @@ -164,6 +169,46 @@ pub fn normalize(doc: &mut Document, before: &Document) { cmd::comment::follow_anchors(doc, before); } +#[cfg(test)] +thread_local! { + /// Tests: every touched layer goes through every step, to compare with the shortcut. + pub(crate) static NO_MOVE_SHORTCUT: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// The layers of `touched` whose transform is all that changed since `before`, in no component or +/// instance (whose copies and overrides follow moves). With variables and styles unchanged, their +/// bindings (never a position), style links, sizing and text have nothing to follow (`before` is +/// settled), so steps 0 to 2 skip them: a drag moving hundreds of layers re-runs only what sits +/// around them (#1571). +pub(crate) fn only_moved_layers(doc: &mut Document, before: &Document, touched: &BTreeSet) -> BTreeSet { + #[cfg(test)] + if NO_MOVE_SHORTCUT.with(std::cell::Cell::get) { + return BTreeSet::new(); + } + let mut out = BTreeSet::new(); + for &id in touched { + let (Some(b), Some(n)) = (before.node_arc(id), doc.node_arc(id)) else { continue }; + if Arc::ptr_eq(b, n) || b.transform == n.transform { + continue; + } + let copied = |n: &Node| matches!(n.kind, figcraft_doc::NodeKind::Component { .. } | figcraft_doc::NodeKind::Instance { .. }); + if std::iter::successors(Some(&**n), |n| n.parent.and_then(|p| doc.node(p))).any(copied) { + continue; + } + // Compared with the old transform put back in place (no copy of the layer). + let Some(n) = doc.node_mut(id) else { continue }; + let moved = std::mem::replace(&mut n.transform, b.transform); + let same = *n == **b; + n.transform = moved; + if same { + out.insert(id); + } + } + // `node_mut` marked them touched again: they are in `touched` already. + doc.take_dirty(); + out +} + /// Whether auto-width text `n` keeps its right edge when its width changes: it was already /// right-aligned auto-width text before the edit and no auto layout flow places it. fn keeps_right_edge(doc: &Document, before: &Document, n: &Node) -> bool { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 199d21fd..b4046193 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -399,6 +399,8 @@ mod measure_tests; #[cfg(test)] mod menu_gaps_tests; #[cfg(test)] +mod move_shortcut_tests; +#[cfg(test)] mod moved_by_tests; #[cfg(test)] mod nested_override_tests; diff --git a/crates/engine/src/move_shortcut_tests.rs b/crates/engine/src/move_shortcut_tests.rs new file mode 100644 index 00000000..dd35b46c --- /dev/null +++ b/crates/engine/src/move_shortcut_tests.rs @@ -0,0 +1,142 @@ +//! Layers that only moved skip the steps of [`normalize`] that follow a layer's own content +//! (#1571): the document must come out exactly as a full normalisation leaves it, for any set of +//! layers moved, in the example files and during a drag. + +use figcraft_doc::{Document, NodeId}; +use figcraft_geom::kurbo::Affine; +use serde_json::json; + +use crate::Session; +use crate::cmd::file::load_path; +use crate::derived::{NO_MOVE_SHORTCUT, only_moved_layers}; +use crate::normalize; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind}; + +/// Normalise `doc` against `before`, with the shortcut or without it. +fn normalized(mut doc: Document, before: &Document, shortcut: bool) -> Document { + NO_MOVE_SHORTCUT.with(|c| c.set(!shortcut)); + normalize(&mut doc, before); + NO_MOVE_SHORTCUT.with(|c| c.set(false)); + doc +} + +/// `doc` settled: everything normalised until nothing changes. +fn settled(mut doc: Document) -> Document { + for _ in 0..3 { + let before = doc.clone(); + let all: Vec = doc.ids().collect(); + doc.mark_dirty(all); + normalize(&mut doc, &before); + } + doc.take_dirty(); + doc +} + +/// `base` with each of `ids` moved by (13, -7) in its parent. +fn moved(base: &Document, ids: &[NodeId]) -> Document { + let mut doc = base.clone(); + for id in ids { + if let Some(n) = doc.node_mut(*id) { + n.transform = Affine::translate((13.0, -7.0)) * n.transform; + } + } + doc +} + +#[test] +fn moving_layers_of_the_examples_matches_a_full_normalise() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples"); + let mut paths: Vec<_> = + std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).filter(|p| p.extension().is_some_and(|e| e == "figcraft")).collect(); + paths.sort(); + assert!(!paths.is_empty()); + for p in paths { + let name = p.file_name().unwrap().to_string_lossy().into_owned(); + let base = settled(load_path(&p.to_string_lossy(), None).unwrap().0.doc); + let layers: Vec = base.ids().filter(|i| !base.pages.contains(i)).collect(); + let top: Vec = base.pages.iter().flat_map(|pg| base.get(*pg).unwrap().children.clone()).collect(); + let every_third: Vec = layers.iter().copied().step_by(3).collect(); + for (case, ids) in [("top-level layers", &top), ("every layer", &layers), ("every third layer", &every_third)] { + let doc = moved(&base, ids); + let mut probe = doc.clone(); + let touched = probe.take_dirty(); + assert!(!only_moved_layers(&mut probe, &base, &touched).is_empty(), "{name}, {case}: some layers take the shortcut"); + assert!(normalized(doc.clone(), &base, true) == normalized(doc, &base, false), "{name}, {case}: as a full normalisation"); + } + } +} + +#[test] +fn layers_that_did_more_than_move_go_through_every_step() { + let mut s = Session::new(); + let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()["id"]).unwrap(); + let base = s.doc().unwrap().clone(); + let mut doc = moved(&base, &[r]); + doc.node_mut(r).unwrap().width = 90.0; + let touched = doc.take_dirty(); + assert!(only_moved_layers(&mut doc, &base, &touched).is_empty(), "resized too"); + let mut doc = base.clone(); + doc.mark_dirty([r]); + let touched = doc.take_dirty(); + assert!(only_moved_layers(&mut doc, &base, &touched).is_empty(), "touched, but unchanged"); +} + +fn rect(s: &mut Session, parent: Option, x: f64, y: f64) -> NodeId { + let mut p = json!({"x": x, "y": y, "width": 40, "height": 40}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id_of(&s.execute("shape.rectangle", &p).unwrap()["id"]).unwrap() +} + +/// Plain layers (one bound to variables), a layer in a group, one ignoring its frame's auto layout +/// and one in a main component with an instance, all selected; then pressed at (20, 20) and +/// dragged through `to`. +fn drag_preview(shortcut: bool, to: &[(f64, f64)]) -> (Document, Document) { + NO_MOVE_SHORTCUT.with(|c| c.set(!shortcut)); + let mut s = Session::new(); + let mut sel = vec![rect(&mut s, None, 0.0, 0.0), rect(&mut s, None, 100.0, 0.0)]; + s.execute("variable.createCollection", &json!({"name": "T"})).unwrap(); + s.execute("variable.create", &json!({"collection": "T", "name": "brand", "type": "color", "value": "#FF0000"})).unwrap(); + s.execute("variable.create", &json!({"collection": "T", "name": "wide", "type": "number", "value": 64})).unwrap(); + for (field, variable) in [("fill", "brand"), ("width", "wide")] { + s.execute("variable.bind", &json!({"ids": [sel[1]], "field": field, "variable": variable})).unwrap(); + } + assert_eq!(s.doc().unwrap().node(sel[1]).unwrap().bound.len(), 2); + let g1 = rect(&mut s, None, 0.0, 200.0); + let g2 = rect(&mut s, None, 80.0, 200.0); + s.execute("object.group", &json!({"ids": [g1, g2]})).unwrap(); + sel.push(g1); + let f = id_of(&s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 300, "height": 300})).unwrap()["id"]).unwrap(); + rect(&mut s, Some(f), 310.0, 10.0); + let free = rect(&mut s, Some(f), 400.0, 100.0); + s.execute("layout.add", &json!({"ids": [f]})).unwrap(); + s.execute("node.set", &json!({"id": free, "props": {"absolute": true}})).unwrap(); + sel.push(free); + let c = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 400, "width": 200, "height": 200})).unwrap()["id"]).unwrap(); + sel.push(rect(&mut s, Some(c), 10.0, 410.0)); + let main = id_of(&s.execute("component.create", &json!({"ids": [c]})).unwrap()["id"]).unwrap(); + s.execute("instance.create", &json!({"component": main, "x": 300, "y": 400})).unwrap(); + s.execute("select.set", &json!({"ids": sel})).unwrap(); + assert_eq!(s.selection().len(), sel.len()); + let ev = |kind, (x, y): (f64, f64)| PointerEvent::at(kind, x, y, 1.0); + s.pointer(&ev(PointerKind::Down, (20.0, 20.0))).unwrap(); + for p in to { + s.pointer(&ev(PointerKind::Move, *p)).unwrap(); + } + let preview = s.doc().unwrap().clone(); + s.pointer(&ev(PointerKind::Up, *to.last().unwrap())).unwrap(); + NO_MOVE_SHORTCUT.with(|c| c.set(false)); + (preview, s.doc().unwrap().clone()) +} + +#[test] +fn a_drag_previews_and_drops_as_with_a_full_normalise() { + let path = [(30.0, 25.0), (61.0, 47.0), (44.0, 90.0)]; + let (preview, dropped) = drag_preview(true, &path); + let (full_preview, full_dropped) = drag_preview(false, &path); + assert!(preview == full_preview, "the preview"); + assert!(dropped == full_dropped, "the drop"); + assert!(preview.ids().count() > 8); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 5765a7a4..c9bf0b86 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-30 · **Change:** minor (damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); 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 @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1571 (part 4): every move of a drag copies the document from where the drag started and normalises it. The copy was already cheap, since layers are shared, not copied (about 0 ms). Normalising took 1.4 ms per move with 648 layers dragged, because each moved layer went through every step: variable bindings and style links, sizing coherence, text auto-size and the instance sync. Now `normalize` sends a layer whose transform is all that changed, and which is in no component or instance, only through the steps that follow what sits around it: groups refitting, auto layout, connectors, ready-for-dev marks and comment pins. Its bindings (never a position), style links, sizing and text have nothing to follow. This applies to any edit that only moves layers, not just drags. Normalising those 648 moves now takes 0.78 ms. Tests compare the result with a full normalisation for every layer, the top-level layers and every third layer of each example file moved at once, and for a drag of plain, bound, grouped, absolutely placed and component layers, while dragging and after the drop. | | 2026-10-30 | minor | M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets; an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, still skipping damaged faces and reading later faces' name tables without reading outlines. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged. | | 2026-10-30 | minor | M6, #1646: a layer pasted from another design app whose image the clipboard didn't carry came in flat grey, like a grey the design chose, and the paste said only that some kinds of content were left out. Now the placeholder an import stores for an image it doesn't have is light grey crossed by darker diagonal stripes (REST JSON, `.fig` and paste alike), the fill keeps the image's own name, the `.fig` and paste note is a fixed, translated one per image ("image fills whose image isn't in the file show a missing-image placeholder and keep its name"), `edit.pasteDesignLayers` returns `missingImages` (the names of the pasted layers concerned), the paste's message names them, and a paste with notes opens the import report like an import. Tests: `fig/missing_image_tests.rs`, `paste_missing_image_tests.rs`, `import_report_tests.rs`. | | 2026-10-30 | minor | M6, #1638: layers pasted from another design app lost the colours of their shared paint styles: a component's glyphs, and the same glyphs in its instances, came in black. The clipboard links each style by the guid it has in the library it came from, which the clipboard doesn't hold, as well as by its library asset, whose copy it does hold; the `.fig` mapping took the guid and found nothing. Now a guid names a style, variable collection or other resource only when the file holds a node by it, and otherwise the asset it also names finds the file's copy, so `.fig` import and paste agree. A link to a style the file really doesn't hold keeps the colours stored with the layer, as before, now with a note. Tests: `paste_style_tests.rs` (design data built in code, read as paste reads it). | From 838d3687125b57e009479530fe32cf6bb41f6ddd Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 21:10:04 +0700 Subject: [PATCH 03/10] M6: .fig connector labels without a saved snapshot are kept, centred on their line (#1659) * M6: .fig connector labels without a saved snapshot are kept, centred on their line A connector with its own label text but no saved snapshot imports as a frame holding its line (a vector with the connector's stroke and ends) and the label as text centred on the line's middle by length, in the connector's fill paints or black; its background isn't known and is left out, as the import report says. The connector branch of layer_filtered moves into connector_layer. Gap 1. * Docs: drop a status item the merge listed twice --- ROADMAP.md | 3 +- crates/format/src/fig/map/connector.rs | 141 +++++++++++++++++++++++-- crates/format/src/fig/map/layer.rs | 27 ++--- crates/format/src/fig/tail_tests.rs | 39 +++++++ crates/ui-egui/locales/en.toml | 1 + docs/file-format-parity.md | 3 +- docs/gaps.md | 5 +- docs/roadmap.md | 1 + 8 files changed, 194 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a17e0919..ac449635 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-30 · **Change:** minor (a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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 @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, gap 1: a `.fig` connector with a label but no saved snapshot of it lost the label. It now imports as a connector with a saved snapshot does: a frame holding its line (a vector with the connector's stroke and ends) and the label as text, centred on the middle of the line by length in a 200-wide box, in the connector's fill paints or black. The background the label sits on isn't known without the snapshot and is left out; the import report says so. Checked by rendering a synthetic file with figcraft-cli; not on real files. Numbers unchanged. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label`. | | 2026-10-30 | minor | M8, #1571 (part 4): every move of a drag copies the document from where the drag started and normalises it. The copy was already cheap, since layers are shared, not copied (about 0 ms). Normalising took 1.4 ms per move with 648 layers dragged, because each moved layer went through every step: variable bindings and style links, sizing coherence, text auto-size and the instance sync. Now `normalize` sends a layer whose transform is all that changed, and which is in no component or instance, only through the steps that follow what sits around it: groups refitting, auto layout, connectors, ready-for-dev marks and comment pins. Its bindings (never a position), style links, sizing and text have nothing to follow. This applies to any edit that only moves layers, not just drags. Normalising those 648 moves now takes 0.78 ms. Tests compare the result with a full normalisation for every layer, the top-level layers and every third layer of each example file moved at once, and for a drag of plain, bound, grouped, absolutely placed and component layers, while dragging and after the drop. | | 2026-10-30 | minor | M10: Bosnian (`bs`, Bosanski) draft catalog: all 1,901 UI strings, 629 command labels and 31 menu wordings, machine-drafted in Latin script (ijekavian) and waiting for a native speaker's review. Bosnian's integer plural rule (one for 1, 21, 31… except 11; few for 2–4, 22–24… except 12–14; other otherwise) in `i18n::plural`. Numbers unchanged. Tests: `bosnian_tags_and_integer_plurals`. | | 2026-10-30 | minor | Text style dedupe is O(spans) (M8, #1102): `shape_text`'s scan over collected styles replaced by a last-two cache and fingerprint buckets resolved with the full equality — same result, O(spans) for the styles that usually differ, never worse than the scan; the font-db write lock untouched (face loading needs it). `layout_dedupe_tests`. Numbers unchanged | diff --git a/crates/format/src/fig/map/connector.rs b/crates/format/src/fig/map/connector.rs index 34148133..e165e80a 100644 --- a/crates/format/src/fig/map/connector.rs +++ b/crates/format/src/fig/map/connector.rs @@ -3,21 +3,59 @@ use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, generated_rec use figcraft_doc::geom::kurbo::{BezPath, PathEl, Rect, Shape}; use figcraft_doc::{RouteEnd, Routing, Side, route_points}; +/// How a connector imports: from its saved snapshot (`terminal`: as one vector, no room for its +/// label), or drawn from its ends, as a frame with its line and `label` when it has one. +#[derive(Clone, Copy)] +pub(super) struct ConnectorShape { + pub snapshot: bool, + pub terminal: bool, + pub label: bool, +} + impl<'a> Mapper<'a> { + /// A connector's own properties on `o`; returns the children it adds (a line and a label). + pub(super) fn connector_layer(&mut self, o: &mut Obj, v: &View<'a>, rest_id: &str, space: Affine, shape: ConnectorShape) -> Vec { + if shape.snapshot { + if shape.terminal { + self.connector_snapshot_flatten(o, v, rest_id, space); + } else { + o.insert("fills".into(), json!([])); + o.insert("strokes".into(), json!([])); + for key in ["styles", "strokeWeight", "strokeAlign", "strokeJoin", "strokeDashes", "strokeCap"] { + o.remove(key); + } + o.insert("clipsContent".into(), json!(false)); + } + return Vec::new(); + } + let mid = self.connector(o, v, space); + if shape.label { + return self.connector_label(o, v, rest_id, mid); + } + if connector_has_saved_label(v) { + self.warn("connector labels were skipped"); + } + Vec::new() + } + + /// Whether a connector without a snapshot keeps its label: it has text of its own and there is + /// room for its two children under the depth and layer limits. + pub(super) fn connector_label_fits(&self, v: &View<'a>, depth: usize) -> bool { + own_text(v).is_some() && depth < super::MAX_DEPTH && self.count.saturating_add(2) < self.budget() + } + /// A connector's end caps, and when it has no saved outline or network its line between its /// ends (free points, or a sibling layer): straight to the point given on the layer (its /// centre unless given), or elbowed or curved from a side of it, as a board connector runs. - pub(super) fn connector(&mut self, o: &mut Obj, v: &View<'a>, space: Affine) { + /// Returns the middle of that line by length, in the connector's own coordinates. + pub(super) fn connector(&mut self, o: &mut Obj, v: &View<'a>, space: Affine) -> Option { for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] { if let Some(c) = v.str(from) { o.insert(to.into(), json!(cap_name(c))); } } - if connector_has_saved_label(v) { - self.warn("connector labels were skipped"); - } if ["vectorNetwork", "vectorNetworkRef", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) { - return; + return None; } let routing = match v.str("connectorLineStyle") { Some("ELBOWED") => Routing::Elbow, @@ -56,7 +94,7 @@ impl<'a> Mapper<'a> { }; let Some(pts) = end("connectorStart").zip(end("connectorEnd")).and_then(|(a, b)| route_points(a, b, routing)) else { self.warn("connectors whose ends can't be placed are empty"); - return; + return None; }; if routing != Routing::Straight { self.warn("elbowed and curved connectors without a saved path follow FigCraft's own routing"); @@ -81,6 +119,55 @@ impl<'a> Mapper<'a> { }; let vertices: Vec = vertices.iter().map(|p| json!({"x": p.x - x0, "y": p.y - y0})).collect(); o.insert("vectorNetwork".into(), json!({"vertices": vertices, "segments": segments})); + middle(&path).map(|m| m - b.origin().to_vec2()) + } + + /// A connector with a label but no saved snapshot of it, as the frame a saved one imports as: + /// `o` keeps the connector's place and size and gives its line to a vector child, and the + /// label is a text child centred on `mid` (the line's middle; the frame's centre when the line + /// came saved). Returns the two children. + pub(super) fn connector_label(&mut self, o: &mut Obj, v: &View<'a>, rest_id: &str, mid: Option) -> Vec { + let (w, h) = o.get("size").map_or((0.0, 0.0), |s| (s["x"].as_f64().unwrap_or(0.0), s["y"].as_f64().unwrap_or(0.0))); + let mut line = Obj::new(); + line.insert("id".into(), json!(format!("{rest_id};line"))); + line.insert("type".into(), json!("VECTOR")); + line.insert("name".into(), json!(format!("{} line", v.str("name").unwrap_or("Connector")))); + line.insert("size".into(), json!({"x": w, "y": h})); + line.insert("relativeTransform".into(), rows(Affine::IDENTITY)); + // The connector's fill paints colour its label; the line is only stroked. + let label_fills = o.remove("fills").filter(|f| f.as_array().is_some_and(|a| !a.is_empty())); + for key in LINE_KEYS { + if let Some(value) = o.remove(*key) { + line.insert((*key).into(), value); + } + } + o.insert("fills".into(), json!([])); + o.insert("strokes".into(), json!([])); + o.insert("clipsContent".into(), json!(false)); + + let mut text = Obj::new(); + text.insert("id".into(), json!(format!("{rest_id};label"))); + text.insert("type".into(), json!("TEXT")); + self.text(&mut text, v); + let characters = text.get("characters").and_then(Value::as_str).unwrap_or_default().to_string(); + text.insert("name".into(), json!(characters)); + text.insert("fills".into(), label_fills.unwrap_or_else(|| json!([{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0, "a": 1}}]))); + // Centred on the line's middle in a box of the label's width, as tall as its lines (laid out + // again with the fonts at hand, so the box only centres it). + let size = text.get("style").and_then(|s| s["fontSize"].as_f64()).filter(|s| s.is_finite() && *s > 0.0).unwrap_or(16.0); + let lines = characters.lines().count().max(1) as f64; + let (bw, bh) = (LABEL_WIDTH, lines * size * 1.4); + if let Some(style) = text.get_mut("style").and_then(Value::as_object_mut) { + style.insert("textAlignHorizontal".into(), json!("CENTER")); + style.insert("textAlignVertical".into(), json!("CENTER")); + style.insert("textAutoResize".into(), json!("NONE")); + } + let at = mid.unwrap_or(Point::new(w / 2.0, h / 2.0)); + text.insert("size".into(), json!({"x": bw, "y": bh})); + text.insert("relativeTransform".into(), rows(Affine::translate((at.x - bw / 2.0, at.y - bh / 2.0)))); + self.count += 2; + self.warn("connector labels without a saved snapshot are centred on the line, without their background"); + vec![Value::Object(line), Value::Object(text)] } pub(super) fn connector_snapshot_supported(&self, v: &View<'a>) -> bool { @@ -311,3 +398,45 @@ fn side(e: &Kv<'_>, at: Option<(f64, f64)>) -> Side { }, } } + +/// What a connector's line carries, moved to the line child when the connector becomes a frame. +const LINE_KEYS: &[&str] = &[ + "vectorNetwork", + "vectorNetworkRef", + "fillGeometry", + "strokeGeometry", + "fills", + "strokes", + "strokeWeight", + "strokeAlign", + "strokeJoin", + "strokeDashes", + "strokeCap", + "strokeCapStart", + "strokeCapEnd", + "strokeWidthProfile", + "styles", +]; + +/// How wide a label without a saved snapshot is laid out, centred on its connector. +const LABEL_WIDTH: f64 = 200.0; + +/// The point halfway along `path` by length. +fn middle(path: &BezPath) -> Option { + let mut pts: Vec = Vec::new(); + figcraft_doc::geom::kurbo::flatten(path.iter(), 0.25, |el| { + if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el { + pts.push(p); + } + }); + let total: f64 = pts.windows(2).map(|s| (s[1] - s[0]).hypot()).sum(); + let mut left = total / 2.0; + for s in pts.windows(2) { + let l = (s[1] - s[0]).hypot(); + if l >= left && l > 0.0 { + return Some(s[0].lerp(s[1], left / l)); + } + left -= l; + } + pts.first().copied() +} diff --git a/crates/format/src/fig/map/layer.rs b/crates/format/src/fig/map/layer.rs index 461513af..dd01cf0a 100644 --- a/crates/format/src/fig/map/layer.rs +++ b/crates/format/src/fig/map/layer.rs @@ -1,5 +1,5 @@ use super::component::{Entry, Inst, OverrideRow, prop_ref}; -use super::connector::connector_has_saved_label; +use super::connector::{ConnectorShape, connector_has_saved_label}; use super::layout::{layout_grid, tracks}; use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke}; use super::{ @@ -155,7 +155,9 @@ impl<'a> Mapper<'a> { connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget()); // A supported connector snapshot has its own note below instead of the generic stand-in one. let stand = stand_in(own).filter(|_| !connector_snapshot); - let ty = Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)?; + // A label without a snapshot: the connector becomes a frame holding its line and its label. + let connector_label = own == "CONNECTOR" && !connector_snapshot && self.connector_label_fits(&view, depth); + let ty = if connector_label { "FRAME" } else { Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)? }; if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") { self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " "))); } @@ -206,20 +208,12 @@ impl<'a> Mapper<'a> { } _ => {} } - if own == "CONNECTOR" && connector_snapshot { - if connector_terminal { - self.connector_snapshot_flatten(&mut o, &view, &rest_id, space); - } else { - o.insert("fills".into(), json!([])); - o.insert("strokes".into(), json!([])); - for key in ["styles", "strokeWeight", "strokeAlign", "strokeJoin", "strokeDashes", "strokeCap"] { - o.remove(key); - } - o.insert("clipsContent".into(), json!(false)); - } - } else if own == "CONNECTOR" { - self.connector(&mut o, &view, space); - } + let connector_kids = if own == "CONNECTOR" { + let shape = ConnectorShape { snapshot: connector_snapshot, terminal: connector_terminal, label: connector_label }; + self.connector_layer(&mut o, &view, &rest_id, space, shape) + } else { + Vec::new() + }; if ty == "COMPONENT" || ty == "COMPONENT_SET" { self.component(&mut o, &view, &rest_id); } @@ -250,6 +244,7 @@ impl<'a> Mapper<'a> { included, slot_content, }); + kids.splice(0..0, connector_kids); if stand.is_some_and(|s| s.0 == "FRAME") { if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) { kids.splice(0..0, snapshot); diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 4311df43..2c8cce03 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -666,6 +666,45 @@ fn connectors_without_a_saved_path_follow_their_line_style() { assert!(warnings.iter().any(|w| w.contains("FigCraft's own routing")), "{warnings:?}"); } +/// A connector with a label but no saved snapshot: a frame holding its line and its label, +/// centred on the line's middle, in black when the connector gives no paint. +#[test] +fn a_labelled_connector_without_a_snapshot_keeps_its_label() { + let mut message = tail_message(); + let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() }; + let box_at = |id, name: &str, x| node(id, Some((1, "*")), "RECTANGLE", name, vec![("size", xy(100.0, 100.0)), ("transform", at(x, 600.0))]); + let side = |node, x| o(vec![("endpointNodeID", g(node)), ("relativePosition", xy(x, 0.5))]); + let labelled = node( + 62, + Some((1, "+")), + "CONNECTOR", + "Says", + vec![ + ("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)])), + ("connectorStart", side(60, 1.0)), + ("connectorEnd", side(61, 0.0)), + ("connectorLineStyle", e("STRAIGHT")), + ("textData", text_data("yes")), + ("fontSize", f(20.0)), + ], + ); + nodes.extend([box_at(60, "A", 0.0), box_at(61, "B", 300.0), labelled]); + let (doc, warnings) = import(&canvas(&schema(), &message), "Labels").unwrap(); + let says = named(&doc, "Says"); + assert!(matches!(says.kind, NodeKind::Frame), "{:?}", says.kind); + assert!(says.fills.is_empty() && says.strokes.is_empty(), "the frame only holds the line and the label"); + let kids: Vec<&figcraft_doc::Node> = says.children.iter().map(|c| doc.node(*c).unwrap()).collect(); + assert_eq!(kids.len(), 2); + assert!(matches!(kids[0].kind, NodeKind::Vector { .. }) && kids[0].strokes.len() == 1, "the line keeps its stroke"); + assert!(matches!(&kids[1].kind, NodeKind::Text(t) if t.characters == "yes"), "{:?}", kids[1].kind); + assert_eq!(kids[1].fills.len(), 1, "black when the connector gives no paint"); + // Centred on the line's middle, (200, 650) on the page. + let label = doc.world_transform(kids[1].id) * figcraft_doc::geom::Point::new(kids[1].width / 2.0, kids[1].height / 2.0); + assert!((label.x - 200.0).abs() < 1e-6 && (label.y - 650.0).abs() < 1e-6, "{label:?}"); + assert!(warnings.iter().any(|w| w.contains("centred on the line")), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("connector labels were skipped")), "{warnings:?}"); +} + #[test] fn variable_prototype_actions_import() { let (doc, warnings) = import(&tail_canvas(), "Tail").unwrap(); diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index edb77e90..0a3274e9 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1710,6 +1710,7 @@ name = "English" "connector label was skipped: text style data is missing" = "connector label was skipped: text style data is missing" "connector label was skipped: text transform is invalid" = "connector label was skipped: text transform is invalid" "connector labels were skipped" = "connector labels were skipped" +"connector labels without a saved snapshot are centred on the line, without their background" = "connector labels without a saved snapshot are centred on the line, without their background" "connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" "connector snapshot vector data is missing; connector was left empty" = "connector snapshot vector data is missing; connector was left empty" "connectors whose ends can't be placed are empty" = "connectors whose ends can't be placed are empty" diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9eccf4cb..1b22ee0c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234), and the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (four decoded values per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows the file's size too), so a 15.4 MB synthetic file with 300,000 layers opens whole; ZIP image reads have a separate total budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, with the 256 MiB per-image cap kept: a member that cannot fit is skipped before its output buffer is allocated, and the import report names the budget and counts skipped images (#1076). Failed reads and invalid image contents still consume the bytes read; a reference used by several layers is read once. This is not a limit on decoded pixels or total import memory; images carried inside the message are unchanged. The file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Elbowed and curved connectors without a saved path are routed as FigCraft's board connectors are (right angles, or one curve, from the sides of the layers they join). Not read: labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234), and the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (four decoded values per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows the file's size too), so a 15.4 MB synthetic file with 300,000 layers opens whole; ZIP image reads have a separate total budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, with the 256 MiB per-image cap kept: a member that cannot fit is skipped before its output buffer is allocated, and the import report names the budget and counts skipped images (#1076). Failed reads and invalid image contents still consume the bytes read; a reference used by several layers is read once. This is not a limit on decoded pixels or total import memory; images carried inside the message are unchanged. The file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Elbowed and curved connectors without a saved path are routed as FigCraft's board connectors are (right angles, or one curve, from the sides of the layers they join). A connector's label without a saved snapshot is kept as text centred on its line, without its background. Not read: table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | | `.penpot` (Penpot 2.x ZIP export) | — | **yes, offline** (open, import, drop, CLI, MCP) | **yes, offline** (export, CLI convert) | Editable layers, supported flex/grid layout, components, tokens and prototypes using the existing model. Unsupported layouts retain saved positions and bounded metadata; toggle-overlay and embedded font binaries are reported. Font references remain editable; no downloads. Synthetic regression coverage; live Penpot and cross-platform QA remain open. See [Penpot reference](penpot.md). | further live Penpot and cross-platform QA | | `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, stretching filling every item across, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides; from Sketch 64, before which a group's fill draws nothing), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); operands' own borders ignored as Sketch does; open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (their image under their style's fills; colour adjustments as image filters with Sketch's tone curve, contrast and then brightness added), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, gradients mixed in OKLab or OKLCH as sRGB stops along the same colours, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer, motion and background blurs (Sketch's radius is the standard deviation), opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does, and its opacity fades it and what it clips together; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, also on shapes' image fills, layer and text styles, nested paths), clipping only where the master clips (`clippingBehavior` 1), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only when the master includes them in both instances and exports, as on Sketch's canvas and renders; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open, an auto-width one growing from its aligned edge. Warnings: noise fills, OKLab and OKLCH gradients, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%); the rules for master backgrounds, symbol clipping, mask opacity, bitmap fills, image overrides, stretching stacks, blur radii, colour adjustments, gradient interpolation and text growth checked against Sketch's renders of files built for each case (black-box, synthetic) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session). Known limitations: a plain group (not a frame) that fills a stack keeps its layers' size where Sketch stretches them; a font Sketch can't find draws in the installed family's own weight (Sketch falls back to a regular face); text in the system SF Pro comes out a few percent narrower than Sketch's; macOS's reserved system fonts (PingFang) aren't found; brightness past about +0.4 is approximated (image filters can't add); OKLab gradients become sRGB stops (FigCraft gradients mix in sRGB) | | `.jam` (whiteboards) | native to FigJam | **yes, best effort** (`file.open`, `file.import`, File ▸ Open, drop, CLI, MCP) | no | the same archive as a `.fig` (its canvas header reads `fig-jam.`) through the same reader (#1342): it opens as a board (the file kind; a `.fig` stays a design file), with generated sticky and shape backgrounds and text kept (#1341) as ordinary layers rather than native board objects (see [boards-parity](boards-parity.md)); tables, stamps, code blocks and the like come in as frames with their text and connectors as vectors, with an import note; a layer type the reader doesn't know is left out with a note naming it; writing `.jam` is not supported | — | @@ -187,6 +187,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | `.fig` import: a connector's label without a saved snapshot was skipped; now the connector imports as a frame holding its line and the label, as a connector with a saved snapshot does, the label centred on the line's middle by length (in a 200-wide box, laid out again with the fonts at hand), in the connector's fill paints or black. Its background isn't known without the snapshot and is left out. Not checked on real files. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` | | 2026-10-30 | minor | Pasted content row: an image the clipboard doesn't carry keeps its name and shows a placeholder that reads as missing; the paste names those layers in its message and opens the import report with its notes (#1646). Numbers unchanged | | 2026-10-30 | minor | Pasted content row: styles a pasted layer links by its library's guid are found by their library asset, so paint-style colours on pasted components and instances no longer import as black; a link to a style that isn't there keeps the stored colour, with a note (#1638). Numbers unchanged | | 2026-10-30 | minor | `.fig` import: elbowed and curved connectors saved without their path were drawn straight; they now run as FigCraft's board connectors do, in right angles or one curve, from the side of each layer they join (the file's `magnet`, else the edge its point is on, else the side facing the other end). The router moved from the engine into `figcraft-doc` (`route_points`) so both use it. Straight connectors are unchanged. Not checked on real files. Tests: `fig::tests::tail` (`other_layer_kinds_import_as_the_closest_layers`, `connectors_without_a_saved_path_follow_their_line_style`) | diff --git a/docs/gaps.md b/docs/gaps.md index e2735e7c..304cde10 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -100,8 +100,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe so the differences come from the fonts' advances. Evidence: `crates/text/src/saved_breaks_tests.rs`, `crates/format/src/fig/text_wrap_tests.rs`; not checked on real files. -- **Missing:** labels of - connectors without a saved snapshot, table cells beyond their own layers, live region +- **Missing:** the background of connector labels without a saved snapshot (the label is kept, + centred on its line), table cells beyond their own layers, live region style/variable links, variables bound to fields the editor can't bind and video fills aren't read; text lays out again with local fonts (an unedited text keeps the file's line breaks, but glyph positions, line widths @@ -571,6 +571,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Gap 1: labels of connectors without a saved snapshot are kept, centred on their line (without their background), instead of skipped. Not checked on real files; estimate unchanged. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` | | 2026-10-30 | minor | Gap 6: widget hover styles landed (a layer's `hover` fill, stroke, opacity or text colour shown while the pointer is over it, as a live preview; `widget.hover`); the entry no longer lists hover. Estimate unchanged. | | 2026-10-30 | minor | Gap 1: elbowed and curved connectors without a saved path no longer import as straight lines; they are routed as board connectors are (the shared `figcraft_doc::route_points`). Not checked on real files; estimate unchanged. Tests: `fig::tests::tail::connectors_without_a_saved_path_follow_their_line_style` | | 2026-10-30 | minor | Gap 6: plugin panels and command forms take a `slider` control (a number between a required `min` and `max`), drawn as a slider with its value field beside it; the plugin is called back once when a drag ends, one undo step. "Sliders" leaves the missing list; estimate unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::a_slider_calls_the_plugin_back_once_when_let_go` | diff --git a/docs/roadmap.md b/docs/roadmap.md index c9bf0b86..3348f1bc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, gap 1: `.fig` connector labels without a saved snapshot are kept, centred on the line, instead of skipped. Test: `fig::tests::tail` | | 2026-10-30 | minor | M8, #1571 (part 4): every move of a drag copies the document from where the drag started and normalises it. The copy was already cheap, since layers are shared, not copied (about 0 ms). Normalising took 1.4 ms per move with 648 layers dragged, because each moved layer went through every step: variable bindings and style links, sizing coherence, text auto-size and the instance sync. Now `normalize` sends a layer whose transform is all that changed, and which is in no component or instance, only through the steps that follow what sits around it: groups refitting, auto layout, connectors, ready-for-dev marks and comment pins. Its bindings (never a position), style links, sizing and text have nothing to follow. This applies to any edit that only moves layers, not just drags. Normalising those 648 moves now takes 0.78 ms. Tests compare the result with a full normalisation for every layer, the top-level layers and every third layer of each example file moved at once, and for a drag of plain, bound, grouped, absolutely placed and component layers, while dragging and after the drop. | | 2026-10-30 | minor | M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets; an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, still skipping damaged faces and reading later faces' name tables without reading outlines. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged. | | 2026-10-30 | minor | M6, #1646: a layer pasted from another design app whose image the clipboard didn't carry came in flat grey, like a grey the design chose, and the paste said only that some kinds of content were left out. Now the placeholder an import stores for an image it doesn't have is light grey crossed by darker diagonal stripes (REST JSON, `.fig` and paste alike), the fill keeps the image's own name, the `.fig` and paste note is a fixed, translated one per image ("image fills whose image isn't in the file show a missing-image placeholder and keep its name"), `edit.pasteDesignLayers` returns `missingImages` (the names of the pasted layers concerned), the paste's message names them, and a paste with notes opens the import report like an import. Tests: `fig/missing_image_tests.rs`, `paste_missing_image_tests.rs`, `import_report_tests.rs`. | From 9b1812432f2825afde85b38b09dd630d97795330 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 21:26:15 +0700 Subject: [PATCH 04/10] M6: plugin panels show images (#1639) * M6: plugin panels show images An image control in a plugin panel carries a picture as base64 (at most 256 KB; panel specs up to 1 MB), drawn at its size up to a field's width and named by its label, which shows instead when the picture can't be decoded or is over 4,096 px on a side. Images can't be clicked or be command parameters. Panel and form captions no longer wrap. * Render worker: in_flight counts the watchdog's late preview The watchdog that previews a render running past PREVIEW_AFTER_MS could pass its still-rendering check, lose the race to the full render, and then hand over its preview and wake the UI after Worker::in_flight had gone false. An idle test that had settled then saw a 0 ns repaint whose cause egui reports a frame late (CI on #1639: 'asked for a repaint in 0ns: [app/files.rs:211]', the autosave tick of the previous frame). The watchdog now marks itself busy from before that check until it has handed over and woken (or skipped), and in_flight counts it; the full render's done markers are SeqCst so the watchdog sees a finished render before in_flight can read idle. * Docs: drop a status item the merge listed twice --- ROADMAP.md | 3 +- crates/engine/src/extensions/plugins.rs | 2 +- crates/plugin/src/manifest.rs | 33 ++++++++--- crates/plugin/src/tests.rs | 1 + crates/plugin/src/v2_tests.rs | 8 ++- crates/ui-egui/src/a11y.rs | 5 ++ crates/ui-egui/src/canvas/render_worker.rs | 40 ++++++++----- crates/ui-egui/src/dialogs/plugins_ui.rs | 66 ++++++++++++++++++++-- crates/ui-egui/src/plugin_panel_tests.rs | 32 +++++++++++ docs/gaps.md | 6 ++ docs/plugins.md | 12 ++-- docs/roadmap.md | 3 +- 12 files changed, 174 insertions(+), 37 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ac449635..fb471274 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-30 · **Change:** minor (`.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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 @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, gap 6: plugin panels show pictures. An `image` control carries a PNG, JPEG, WebP, GIF, BMP or TIFF file as base64 (`data`, at most 256 KB; a panel spec may now be 1 MB) and is drawn at its size, at most a field wide and 240 pt tall, never scaled up; screen readers read its `label`. The app decodes it with the thumbnail decoder (its allocation limit), refuses pictures over 4,096 px on a side before decoding them, keeps the last 32 decoded, and shows the label instead of a picture it can't show. Images can't be clicked (`plugin.event` refuses them) and can't be command parameters. Also: a panel's or form's captions no longer wrap ("Palette" broke onto two lines when it was the widest). Numbers unchanged. Tests: `v2_tests`, `tests` (manifest), `plugin_panel_tests::panel_images_show_named_or_fall_back_to_their_label`. | | 2026-10-30 | minor | M6, gap 1: a `.fig` connector with a label but no saved snapshot of it lost the label. It now imports as a connector with a saved snapshot does: a frame holding its line (a vector with the connector's stroke and ends) and the label as text, centred on the middle of the line by length in a 200-wide box, in the connector's fill paints or black. The background the label sits on isn't known without the snapshot and is left out; the import report says so. Checked by rendering a synthetic file with figcraft-cli; not on real files. Numbers unchanged. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label`. | | 2026-10-30 | minor | M8, #1571 (part 4): every move of a drag copies the document from where the drag started and normalises it. The copy was already cheap, since layers are shared, not copied (about 0 ms). Normalising took 1.4 ms per move with 648 layers dragged, because each moved layer went through every step: variable bindings and style links, sizing coherence, text auto-size and the instance sync. Now `normalize` sends a layer whose transform is all that changed, and which is in no component or instance, only through the steps that follow what sits around it: groups refitting, auto layout, connectors, ready-for-dev marks and comment pins. Its bindings (never a position), style links, sizing and text have nothing to follow. This applies to any edit that only moves layers, not just drags. Normalising those 648 moves now takes 0.78 ms. Tests compare the result with a full normalisation for every layer, the top-level layers and every third layer of each example file moved at once, and for a drag of plain, bound, grouped, absolutely placed and component layers, while dragging and after the drop. | | 2026-10-30 | minor | M10: Bosnian (`bs`, Bosanski) draft catalog: all 1,901 UI strings, 629 command labels and 31 menu wordings, machine-drafted in Latin script (ijekavian) and waiting for a native speaker's review. Bosnian's integer plural rule (one for 1, 21, 31… except 11; few for 2–4, 22–24… except 12–14; other otherwise) in `i18n::plural`. Numbers unchanged. Tests: `bosnian_tags_and_integer_plurals`. | diff --git a/crates/engine/src/extensions/plugins.rs b/crates/engine/src/extensions/plugins.rs index dbc08336..ca75ffa7 100644 --- a/crates/engine/src/extensions/plugins.rs +++ b/crates/engine/src/extensions/plugins.rs @@ -568,7 +568,7 @@ fn event(s: &mut Session, p: &Value) -> Result { let c = open .panel .control(&control) - .filter(|c| c.kind != ParamKind::Note) + .filter(|c| !matches!(c.kind, ParamKind::Note | ParamKind::Image)) .ok_or_else(|| bad(cmd, format!("the panel has no control `{control}`")))?; let field = c.is_field(); let value = if field { c.coerce(get(p, "value").unwrap_or(&Value::Null)).map_err(|e| bad(cmd, e))? } else { Value::Null }; diff --git a/crates/plugin/src/manifest.rs b/crates/plugin/src/manifest.rs index 6b13239d..50298e3e 100644 --- a/crates/plugin/src/manifest.rs +++ b/crates/plugin/src/manifest.rs @@ -20,8 +20,10 @@ const MAX_COMMANDS: usize = 64; const MAX_PARAMS: usize = 32; const MAX_OPTIONS: usize = 64; const MAX_ALLOW: usize = 256; -/// Largest panel spec a plugin may show, and its most controls. -pub const MAX_PANEL_BYTES: usize = 64 * 1024; +/// Largest panel spec a plugin may show (images included, as base64), and its most controls. +pub const MAX_PANEL_BYTES: usize = 1024 * 1024; +/// Largest image in a panel (its file's bytes). +pub const MAX_PANEL_IMAGE_BYTES: usize = 256 * 1024; const MAX_CONTROLS: usize = 64; /// Longest text parameter value. const MAX_TEXT_VALUE: usize = 10_000; @@ -143,6 +145,9 @@ pub struct Param { /// The choices of a `select` or `list`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub options: Vec, + /// An `image`'s file (PNG, JPEG…), base64. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub data: String, /// Bounds of a `number` (or a `slider`, which needs both). #[serde(default, skip_serializing_if = "Option::is_none")] pub min: Option, @@ -169,6 +174,8 @@ pub enum ParamKind { Button, /// Panels only: text (`label`, up to 1000 characters). Note, + /// Panels only: a picture (`data`); `label` is what screen readers say. + Image, } impl Param { @@ -177,9 +184,9 @@ impl Param { if self.label.is_empty() { &self.name } else { &self.label } } - /// Whether it holds a value (not a button or a note). + /// Whether it holds a value (not a button, a note or an image). pub fn is_field(&self) -> bool { - !matches!(self.kind, ParamKind::Button | ParamKind::Note) + !matches!(self.kind, ParamKind::Button | ParamKind::Note | ParamKind::Image) } /// The value used when none is given. @@ -193,7 +200,7 @@ impl Param { ParamKind::Boolean => Value::Bool(false), ParamKind::Select | ParamKind::List => self.options.first().map_or(Value::Null, |o| Value::String(o.clone())), ParamKind::Color => Value::String("#000000".into()), - ParamKind::Button | ParamKind::Note => Value::Null, + ParamKind::Button | ParamKind::Note | ParamKind::Image => Value::Null, } } @@ -228,14 +235,14 @@ impl Param { .and_then(figcraft_color::Color::from_hex) .map(|c| Value::String(c.to_css_hex())) .ok_or_else(|| format!("`{name}` must be a hex colour like #FF5A5F")), - ParamKind::Button | ParamKind::Note => Ok(Value::Null), + ParamKind::Button | ParamKind::Note | ParamKind::Image => Ok(Value::Null), } } /// Check a parameter or panel control: its name, label, options and bounds. A default that /// doesn't fit its kind is dropped. fn check(&mut self) -> Result<(), String> { - if !(self.kind == ParamKind::Note && self.name.is_empty()) { + if !(matches!(self.kind, ParamKind::Note | ParamKind::Image) && self.name.is_empty()) { check_id("parameter name", &self.name, MAX_NAME)?; } let max = if self.kind == ParamKind::Note { MAX_TEXT } else { MAX_LABEL }; @@ -255,6 +262,16 @@ impl Param { { return Err(format!("parameter `{}` has bad bounds", self.name)); } + // Base64: four characters for three bytes (the app decodes it, checking it again). + let image = self.data.len() / 4 * 3; + match self.kind { + ParamKind::Image if self.data.is_empty() || image > MAX_PANEL_IMAGE_BYTES + 3 => { + return Err(format!("image `{}` needs `data` (base64) of at most {} KB", self.name, MAX_PANEL_IMAGE_BYTES / 1024)); + } + ParamKind::Image => {} + _ if !self.data.is_empty() => return Err(format!("parameter `{}`: only images take `data`", self.name)), + _ => {} + } if self.kind == ParamKind::Slider && (self.min.is_none() || self.max.is_none()) { return Err(format!("slider `{}` needs a `min` and a `max`", self.name)); } @@ -366,7 +383,7 @@ impl Manifest { for p in &mut c.params { p.check()?; if !p.is_field() { - return Err(format!("parameter `{}`: buttons and notes only go in panels", p.name)); + return Err(format!("parameter `{}`: buttons, notes and images only go in panels", p.name)); } } if let Some(dup) = first_duplicate(c.params.iter().map(|p| p.name.as_str())) { diff --git a/crates/plugin/src/tests.rs b/crates/plugin/src/tests.rs index a2d888c9..940d820b 100644 --- a/crates/plugin/src/tests.rs +++ b/crates/plugin/src/tests.rs @@ -451,6 +451,7 @@ fn bad_manifests_are_refused() { json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "n", "type": "number", "min": 5, "max": 1}]}]}), json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "n"}, {"name": "n"}]}]}), json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "n", "type": "date"}]}]}), + json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "i", "type": "image", "data": "iVBORw0KGgo="}]}]}), json!({"commands": "run"}), ]; for extra in bad { diff --git a/crates/plugin/src/v2_tests.rs b/crates/plugin/src/v2_tests.rs index ce76628d..3b26a599 100644 --- a/crates/plugin/src/v2_tests.rs +++ b/crates/plugin/src/v2_tests.rs @@ -122,6 +122,7 @@ fn panels_are_validated_and_keep_entered_values() { {"type": "color", "name": "accent", "value": "#f0a"}, {"type": "number", "name": "n", "min": 1, "max": 4, "value": 9}, {"type": "text", "name": "t"}, + {"type": "image", "label": "A chart", "data": "iVBORw0KGgo="}, {"type": "slider", "name": "o", "min": 0, "max": 100, "value": -5}, {"type": "button", "name": "go", "label": "Go"} ]}"##, @@ -130,6 +131,7 @@ fn panels_are_validated_and_keep_entered_values() { assert_eq!(p.control("accent").unwrap().default, Some(json!("#FF00AA"))); assert_eq!(p.control("n").unwrap().default, Some(json!(4.0)), "values are clamped"); assert_eq!(p.control("go").unwrap().kind, ParamKind::Button); + assert_eq!(p.controls[5].kind, ParamKind::Image, "an image needs no name and holds no value"); let o = p.control("o").unwrap(); assert_eq!((o.kind, o.default.clone()), (ParamKind::Slider, Some(json!(0.0))), "a slider is clamped like a number"); assert_eq!(o.coerce(&json!("250")), Ok(json!(100.0))); @@ -142,6 +144,7 @@ fn panels_are_validated_and_keep_entered_values() { json!({"palette": "Cool", "accent": "#FF00AA", "n": 4.0, "t": "typed", "o": 0.0}), "the spec's values win, then the entered ones" ); + let big_image = json!({"controls": [{"type": "image", "data": "A".repeat(256 * 1024 / 3 * 4 + 8)}]}).to_string(); let deep = format!("{{\"controls\": {}{}}}", "[".repeat(5000), "]".repeat(5000)); let many = json!({"controls": (0..65).map(|i| json!({"type": "button", "name": format!("b{i}")})).collect::>()}).to_string(); let junk = [ @@ -156,13 +159,16 @@ fn panels_are_validated_and_keep_entered_values() { r#"{"controls": [{"type": "slider", "name": "a", "min": 3}]}"#, r#"{"controls": [{"type": "slider", "name": "a", "max": 3}]}"#, r#"{"title": "line\nbreak"}"#, + r#"{"controls": [{"type": "image", "label": "no data"}]}"#, + r#"{"controls": [{"type": "note", "label": "x", "data": "iVBORw0KGgo="}]}"#, + &big_image, &deep, &many, ]; for j in junk { assert!(Panel::parse(j).is_err(), "accepted {}", j.chars().take(60).collect::()); } - let huge = format!(r#"{{"controls": [{{"type": "note", "label": "{}"}}]}}"#, "x".repeat(70_000)); + let huge = format!(r#"{{"controls": [{{"type": "note", "label": "{}"}}]}}"#, "x".repeat(1_100_000)); assert!(Panel::parse(&huge).unwrap_err().contains("larger")); let long_note = format!(r#"{{"controls": [{{"type": "note", "label": "{}"}}]}}"#, "x".repeat(1001)); assert!(Panel::parse(&long_note).is_err()); diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index fae97a41..50245276 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -910,6 +910,11 @@ pub fn number(resp: &Response, label: &str, value: Option, suffix: &str, em }); } +/// A picture described by `label`. +pub fn image(resp: &Response, label: &str) { + node(resp, label, |n| n.set_role(Role::Image)); +} + /// A slider over `min`..=`max` at `value` (None = mixed). pub fn slider(resp: &Response, label: &str, value: Option, min: f64, max: f64) { node(resp, label, |n| { diff --git a/crates/ui-egui/src/canvas/render_worker.rs b/crates/ui-egui/src/canvas/render_worker.rs index 612b0f65..44a47880 100644 --- a/crates/ui-egui/src/canvas/render_worker.rs +++ b/crates/ui-egui/src/canvas/render_worker.rs @@ -411,6 +411,8 @@ pub struct Worker { /// The newest job the thread is done with: rendered (or skipped for a newer one) and the UI /// woken for it. finished: Arc, + /// The watchdog is drawing a late preview, or has one to hand over and wake the UI for. + previewing: Arc, } #[cfg(not(target_arch = "wasm32"))] @@ -427,6 +429,8 @@ impl Worker { let (watch, watched) = std::sync::mpsc::channel::<(u64, Job, std::time::Instant)>(); let refined = Arc::new(std::sync::atomic::AtomicU64::new(0)); let refined_seen = refined.clone(); + let previewing = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let busy = previewing.clone(); let (preview_tx, preview_ctx) = (done_tx.clone(), ctx.clone()); let watchdog = std::thread::Builder::new().name("figcraft-render-preview".into()).stack_size(figcraft_render::RENDER_STACK).spawn(move || { @@ -441,17 +445,23 @@ impl Worker { Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return, } } - let still_rendering = || refined_seen.load(std::sync::atomic::Ordering::Acquire) < seq; - if !still_rendering() { - continue; - } - let Some(d) = preview_with(&job, &mut retained) else { continue }; - if still_rendering() { - if preview_tx.send(answering(d, seq)).is_err() { - break; - } + // Busy from before it looks at the render until its preview is handed over and the + // UI woken, so `in_flight` holds until then: once the full render is done (stored + // before the render thread's `finished`), this sees it and hands over nothing. + busy.store(true, std::sync::atomic::Ordering::SeqCst); + let still_rendering = || refined_seen.load(std::sync::atomic::Ordering::SeqCst) < seq; + let mut sent = true; + if still_rendering() + && let Some(d) = preview_with(&job, &mut retained) + && still_rendering() + { + sent = preview_tx.send(answering(d, seq)).is_ok(); preview_ctx.request_repaint_of(viewport); } + busy.store(false, std::sync::atomic::Ordering::SeqCst); + if !sent { + break; + } } }); if let Err(e) = watchdog { @@ -482,20 +492,20 @@ impl Worker { let _ = watch.send((seq, job.clone(), std::time::Instant::now())); } let done = renders.refine(&job); - refined.store(seq, std::sync::atomic::Ordering::Release); + refined.store(seq, std::sync::atomic::Ordering::SeqCst); if let Some(d) = done && done_tx.send(answering(d, seq)).is_err() { break; } ctx.request_repaint_of(viewport); - done_with.store(seq, std::sync::atomic::Ordering::Release); + done_with.store(seq, std::sync::atomic::Ordering::SeqCst); } }); if let Err(e) = spawned { log::error!("render thread: {e}"); } - Self { tx, rx, submitted: 0, finished } + Self { tx, rx, submitted: 0, finished, previewing } } pub fn submit(&mut self, job: Job) { @@ -504,10 +514,10 @@ impl Worker { } } - /// Whether a submitted job is still rendering or hasn't woken the UI yet (a finished image - /// may still wait for [`Self::poll`]). + /// Whether a submitted job is still rendering or hasn't woken the UI yet, the watchdog's late + /// preview included (a finished image may still wait for [`Self::poll`]). pub fn in_flight(&self) -> bool { - self.finished.load(std::sync::atomic::Ordering::Acquire) < self.submitted + self.finished.load(std::sync::atomic::Ordering::SeqCst) < self.submitted || self.previewing.load(std::sync::atomic::Ordering::SeqCst) } /// The best of the renders finished since the last poll ([`better`]). diff --git a/crates/ui-egui/src/dialogs/plugins_ui.rs b/crates/ui-egui/src/dialogs/plugins_ui.rs index 067dc574..923ffffa 100644 --- a/crates/ui-egui/src/dialogs/plugins_ui.rs +++ b/crates/ui-egui/src/dialogs/plugins_ui.rs @@ -56,6 +56,9 @@ pub struct PluginsWindow { /// Width of the plugin windows' fields. const FIELD_W: f32 = 220.0; +/// Tallest a panel image is drawn, and the largest picture a panel shows (pixels on a side). +const IMAGE_MAX_H: f32 = 240.0; +const IMAGE_MAX_SIDE: u32 = 4096; /// The value field beside a slider. const SLIDER_VALUE_W: f32 = 56.0; /// How far apart plugin panels open, side by side (a panel and a gap). @@ -608,12 +611,13 @@ fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map { @@ -623,6 +627,10 @@ fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map { + image(ui, p, t); + None + } ParamKind::Button => { ui.horizontal(|ui| { let pressed = row.iter().filter(|b| widgets::button(ui, b.title(), std::mem::take(&mut first_button), true).clicked()); @@ -644,6 +652,54 @@ fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map)>; +const IMAGE_CACHE: usize = 32; + +/// A panel's picture, at most as wide as a field and [`IMAGE_MAX_H`] tall (never scaled up), named +/// for screen readers by its label; its label (or "Image") as text when it can't be shown. +fn image(ui: &mut Ui, p: &Param, t: &Tokens) { + let (cache, key) = (Id::unique("plugin_images"), Id::unique(&p.data)); + let cached = ui.data(|d| d.get_temp::(cache).and_then(|c| c.into_iter().find(|(k, _)| *k == key))); + let tex = match cached { + Some((_, tex)) => tex, + None => { + let tex = decode_image(ui.ctx(), &p.data); + ui.data_mut(|d| { + let c = d.get_temp_mut_or_default::(cache); + c.push((key, tex.clone())); + let over = c.len().saturating_sub(IMAGE_CACHE); + c.drain(..over); + }); + tex + } + }; + let Some(tex) = tex else { + let text: &str = if p.label.is_empty() { tr("Image") } else { &p.label }; + ui.label(RichText::new(text).font(regular(11.0)).color(t.text_secondary)); + return; + }; + let size = tex.size_vec2(); + let size = size * (FIELD_W / size.x).min(IMAGE_MAX_H / size.y).min(1.0); + let resp = ui.add(egui::Image::new(&tex).fit_to_exact_size(size)); + crate::a11y::image(&resp, &p.label); +} + +/// A panel image's texture: its base64 decoded, then the picture (at most [`IMAGE_MAX_SIDE`] +/// pixels on a side, checked before it is decoded), scaled to twice the field's width. +fn decode_image(ctx: &Context, data: &str) -> Option { + use base64::Engine as _; + let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).ok()?; + let (w, h) = figcraft_render::images::dimensions(&bytes)?; + if w > IMAGE_MAX_SIDE || h > IMAGE_MAX_SIDE { + return None; + } + let pic = figcraft_format::thumbnail::decode_thumbnail(&bytes, (FIELD_W * 2.0) as u32)?; + let image = egui::ColorImage::from_rgba_unmultiplied([pic.width as usize, pic.height as usize], &pic.rgba); + Some(ctx.load_texture("plugin-panel-image", image, egui::TextureOptions::LINEAR)) +} + /// One parameter's field, named for screen readers: its new value when it changed. fn field(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option { let name = p.title(); @@ -726,7 +782,7 @@ fn field(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option { } typed } - ParamKind::Button | ParamKind::Note => None, + ParamKind::Button | ParamKind::Note | ParamKind::Image => None, }; v.map(|v| Used::Set(p.name.clone(), v)) } diff --git a/crates/ui-egui/src/plugin_panel_tests.rs b/crates/ui-egui/src/plugin_panel_tests.rs index e857ede9..580fb483 100644 --- a/crates/ui-egui/src/plugin_panel_tests.rs +++ b/crates/ui-egui/src/plugin_panel_tests.rs @@ -22,6 +22,9 @@ pub(crate) fn panel_module() -> Vec { {"type": "color", "name": "accent", "label": "Accent", "value": "#FF5A5F"}, {"type": "select", "name": "shape", "label": "Shape", "options": ["Square", "Circle"]}, {"type": "text", "name": "title", "label": "Title"}, + {"type": "image", "name": "logo", "label": "Brand mark", "data": png(40, 20)}, + {"type": "image", "label": "Broken chart", "data": "bm90IGFuIGltYWdl"}, + {"type": "image", "label": "Too wide", "data": png(5000, 1)}, {"type": "slider", "name": "opacity", "label": "Opacity", "min": 0, "max": 100, "value": 0}, {"type": "button", "name": "go", "label": "Go"} ]}) @@ -46,6 +49,15 @@ pub(crate) fn panel_module() -> Vec { wat::parse_str(&wat).unwrap() } +/// A PNG of `w` × `h` pixels, base64. +fn png(w: u32, h: u32) -> String { + let mut out = Vec::new(); + image::RgbaImage::from_pixel(w, h, image::Rgba([255, 90, 95, 255])) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + base64::engine::general_purpose::STANDARD.encode(out) +} + /// A session with that plugin's panel open. fn session() -> Session { let wasm = panel_module(); @@ -124,6 +136,26 @@ fn a_colour_field_opens_the_colour_picker_and_two_panels_show_side_by_side() { assert_eq!((accents, u.app.session.plugins.panels.len()), (2, 2), "both panels are drawn"); } +/// A panel shows its pictures at their size, named for screen readers; one that can't be decoded, +/// or is too large, shows its label instead; an image isn't a control `plugin.event` can use. +#[test] +fn panel_images_show_named_or_fall_back_to_their_label() { + let mut u = Ui::new(session()); + u.ctx.enable_accesskit(); + u.frame(vec![]); + let nodes = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes; + let images: Vec<_> = nodes.iter().map(|(_, n)| n).filter(|n| n.role() == egui::accesskit::Role::Image).collect(); + assert_eq!(images.len(), 1, "only the decodable picture is drawn"); + assert_eq!(images[0].label(), Some("Brand mark")); + let b = images[0].bounds().unwrap(); + assert_eq!(((b.x1 - b.x0).round(), (b.y1 - b.y0).round()), (40.0, 20.0), "shown at its size"); + let text = + |label: &str| nodes.iter().any(|(_, n)| n.role() != egui::accesskit::Role::Image && (n.label() == Some(label) || n.value() == Some(label))); + assert!(text("Broken chart") && text("Too wide"), "a picture that can't be shown shows its label"); + let err = u.app.session.execute("plugin.event", &json!({"id": "org.test.panel", "control": "logo"})).unwrap_err(); + assert!(err.to_string().contains("no control `logo`"), "{err}"); +} + /// A slider shows the value it is dragged to as it moves and calls the plugin back once, with /// that value, when let go. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 304cde10..13c4130b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -261,7 +261,12 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe Sliders (2026-10-30): a panel control or command parameter of type `slider` (a number between its `min` and `max`) is drawn as a slider with its value beside it; the plugin is called back once, when the drag ends. + Panel images (2026-10-30): an `image` control shows a picture the plugin carries (base64, at most + 256 KB and 4,096 pixels on a side), as wide as a field at most, named by its label. - **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 have no hover or + timers, can't open a panel and draw only frames, text, shapes and images. + Figma's own plugins are JavaScript against Figma's plugin API and and install counts, background update checks, the directory in the browser build; widgets have no timers, can't open a panel and draw only frames, text, shapes and images; no images in panels. Figma's own plugins are JavaScript against Figma's plugin API and @@ -571,6 +576,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Gap 6: plugin panels show pictures: an `image` control (`data` base64, at most 256 KB and 4,096 px a side; panel specs up to 1 MB) drawn at its size up to a field's width, named by its label, which shows instead when the picture can't be decoded. "Images" leaves the missing list; estimate unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::panel_images_show_named_or_fall_back_to_their_label` | | 2026-10-30 | minor | Gap 1: labels of connectors without a saved snapshot are kept, centred on their line (without their background), instead of skipped. Not checked on real files; estimate unchanged. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` | | 2026-10-30 | minor | Gap 6: widget hover styles landed (a layer's `hover` fill, stroke, opacity or text colour shown while the pointer is over it, as a live preview; `widget.hover`); the entry no longer lists hover. Estimate unchanged. | | 2026-10-30 | minor | Gap 1: elbowed and curved connectors without a saved path no longer import as straight lines; they are routed as board connectors are (the shared `figcraft_doc::route_points`). Not checked on real files; estimate unchanged. Tests: `fig::tests::tail::connectors_without_a_saved_path_follow_their_line_style` | diff --git a/docs/plugins.md b/docs/plugins.md index 7da7ca50..ae0ba372 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -72,7 +72,7 @@ A message the plugin shows (`notify`) appears as a toast; so does a text result. **Plugin panels.** A plugin written for API 2 can show a panel while it runs: a floating window, first placed beside the properties panel (drag its title bar to move it), with the plugin's notes, -fields (text, number, slider, checkbox, menu, list, colour) and buttons, drawn with FigCraft's own widgets +pictures, fields (text, number, slider, checkbox, menu, list, colour) and buttons, drawn with FigCraft's own widgets and named for screen readers. A colour field's swatch opens FigCraft's colour picker (the plugin hears of the colour when a drag in the picker ends); its hex can also be typed. Pressing a button or changing a field calls the plugin back with every field's value; what that callback does is one @@ -277,7 +277,7 @@ it can handle): Limits per run, and again per panel callback: 1,000,000,000 fuel (about a second or two of plain WebAssembly in a debug build, far less in a release build), 64 MB of memory, 100,000 host calls (each also costs 10,000 fuel, including `reply_len`, `reply_read` and `set_output`), 16 MB per string either way, 1,000 log lines, and modules up to -16 MB. A panel spec is at most 64 KB with 64 controls; settings are at most 64 KB per plugin (keys +16 MB. A panel spec is at most 1 MB with 64 controls, each image at most 256 KB; 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. A property menu has at most 8 @@ -372,8 +372,10 @@ plugin is removed. ``` `title` defaults to the plugin's name. Controls are the manifest's parameter types (`text`, -`number`, `boolean`, `select`, `list`, `slider`, `color`) plus `button` and `note` (text in `label`, up to -1,000 characters, no `name` needed), in order, each `name` unique. A field shows its `value`; a +`number`, `boolean`, `select`, `list`, `slider`, `color`) plus `button`, `note` (text in `label`, up to +1,000 characters, no `name` needed) and `image` (`data`: a PNG, JPEG, WebP, GIF, BMP or TIFF file as +base64, at most 256 KB and 4,096 pixels on a side; `label` is what screen readers say, and what +shows if the picture can't be; no `name` needed), in order, each `name` unique. A field shows its `value`; a field without one keeps what the user entered before the panel was updated, so a plugin can update a note without resetting the other fields. Buttons next to each other share a row; the first button is the primary one. FigCraft checks the whole spec (sizes, kinds, options, bounds) and drops values @@ -523,7 +525,7 @@ import what you need from `figcraft`. - 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 or free layout inside a panel. +- Panels: no free layout inside a panel; images can't be clicked. - Data in the document: plugins keep data on layers (pages included) with `node.set {props: {pluginData: {key: value}}}` (a `null` value removes a key) and read it back with `node.get`, which reports `pluginData: {key: value}` (values as strings) when a layer has any; there is no separate diff --git a/docs/roadmap.md b/docs/roadmap.md index 3348f1bc..55553f9d 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-30 · **Change:** minor (a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); 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 @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, gap 6: `image` controls in plugin panels (base64, 256 KB, 4,096 px a side; specs up to 1 MB), named by their label. Tests: `v2_tests`, `plugin_panel_tests` | | 2026-10-30 | minor | M6, gap 1: `.fig` connector labels without a saved snapshot are kept, centred on the line, instead of skipped. Test: `fig::tests::tail` | | 2026-10-30 | minor | M8, #1571 (part 4): every move of a drag copies the document from where the drag started and normalises it. The copy was already cheap, since layers are shared, not copied (about 0 ms). Normalising took 1.4 ms per move with 648 layers dragged, because each moved layer went through every step: variable bindings and style links, sizing coherence, text auto-size and the instance sync. Now `normalize` sends a layer whose transform is all that changed, and which is in no component or instance, only through the steps that follow what sits around it: groups refitting, auto layout, connectors, ready-for-dev marks and comment pins. Its bindings (never a position), style links, sizing and text have nothing to follow. This applies to any edit that only moves layers, not just drags. Normalising those 648 moves now takes 0.78 ms. Tests compare the result with a full normalisation for every layer, the top-level layers and every third layer of each example file moved at once, and for a drag of plain, bound, grouped, absolutely placed and component layers, while dragging and after the drop. | | 2026-10-30 | minor | M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets; an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, still skipping damaged faces and reading later faces' name tables without reading outlines. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged. | From 139d5e94a9cc919f2c53ccac6ef7dc824967987d Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 21:42:49 +0700 Subject: [PATCH 05/10] Server tests: rate limits measured at a clock the tests hold still (#1662) downloads_have_a_share_per_address failed once under a full gate: its 40 library fetches could take long enough for the address's download share, measured against Instant::now() and refilling 1 MiB a second, to refill one more. Store::clock() is now what every rate limit the store checks reads: the clock, or in test builds a time the test holds it at (a #[cfg(test)] field). The hardening, download burst and admin budget tests hold it still; the admin test is back to its exact budget (#1564 had allowed for refilling). Found flaking in gates. --- apps/figcraft-server/src/admin_budget_tests.rs | 14 +++----------- apps/figcraft-server/src/download_burst_tests.rs | 2 ++ apps/figcraft-server/src/hardening_tests.rs | 3 +++ apps/figcraft-server/src/store.rs | 14 ++++++++++++++ apps/figcraft-server/src/store/backend.rs | 12 ++++++------ apps/figcraft-server/src/store/files.rs | 4 ++-- apps/figcraft-server/src/store/library.rs | 4 ++-- 7 files changed, 32 insertions(+), 21 deletions(-) diff --git a/apps/figcraft-server/src/admin_budget_tests.rs b/apps/figcraft-server/src/admin_budget_tests.rs index fed3e5d9..71c62897 100644 --- a/apps/figcraft-server/src/admin_budget_tests.rs +++ b/apps/figcraft-server/src/admin_budget_tests.rs @@ -21,18 +21,10 @@ fn a_flood_of_changes_from_one_address_is_held_back_and_others_are_not() { let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); let (file, ..) = store.publish("Poster", &snapshot("Poster")).unwrap(); let flooder = ip("198.51.100.1"); - let start = std::time::Instant::now(); + // The clock held still, so the budget doesn't refill while the requests run. + store.frozen = Some(std::time::Instant::now()); let done = (0..100).filter(|_| rename(&mut store, flooder, &file.id) == AdminReply::Done).count(); - // The budget refills one request a second, so a slow run (a loaded test machine) lets a few more in. - let budget = ADMIN_PER_MINUTE as usize; - let refilled = start.elapsed().as_secs() as usize + 1; - assert!((budget..=budget + refilled).contains(&done), "a minute's worth, then wait: {done} done"); - // Use up whatever refilled meanwhile; the request right after is refused. - for _ in 0..refilled { - if rename(&mut store, flooder, &file.id) != AdminReply::Done { - break; - } - } + assert_eq!(done, ADMIN_PER_MINUTE as usize, "a minute's worth, then wait"); assert_eq!(rename(&mut store, flooder, &file.id), AdminReply::Failed { reason: BUSY_ADMIN.into() }); // An owner token's requests share the address's budget. let hosted = store.hosted(flooder, Some(&file.id), Admin::Rename { file: file.id.clone(), name: "Mine".into() }, &|_| None).reply; diff --git a/apps/figcraft-server/src/download_burst_tests.rs b/apps/figcraft-server/src/download_burst_tests.rs index 61f36730..1a1f0f5d 100644 --- a/apps/figcraft-server/src/download_burst_tests.rs +++ b/apps/figcraft-server/src/download_burst_tests.rs @@ -58,6 +58,8 @@ fn large_downloads_keep_an_address_to_its_rate() { fn a_library_larger_than_the_burst_is_downloaded_then_the_address_waits() { let dir = TempDir::new("biglib"); let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + // The clock held still, so the address's share doesn't refill while the test runs. + store.frozen = Some(Instant::now()); let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap(); store.publish_library(&file.id, &archive(0, 40 * MIB)).unwrap(); let fetch = |s: &mut Store, who: &str| s.library(ip(who), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None }); diff --git a/apps/figcraft-server/src/hardening_tests.rs b/apps/figcraft-server/src/hardening_tests.rs index be28d22d..186b8d9c 100644 --- a/apps/figcraft-server/src/hardening_tests.rs +++ b/apps/figcraft-server/src/hardening_tests.rs @@ -525,6 +525,9 @@ fn downloads_have_a_share_per_address() { // A team library is served within it, then refused, saying why; another address still gets it. let dir = TempDir::new("libshare"); let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + // The clock held still: on a loaded machine the 40 fetches can take seconds, long enough for + // the address's share to refill one more. + store.frozen = Some(t); let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap(); store.publish_library(&file.id, &archive(0, 4 << 20)).unwrap(); let fetch = |s: &mut Store, who: &str| s.library(ip(who), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None }); diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 9e12d328..1feaf706 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -286,6 +286,9 @@ pub struct Store { abuse: Abuse, /// Team library bytes served, everyone together. served: figcraft_collab_net::net::Bucket, + /// Tests: the time the rate limits read, held still (`None`: the clock). + #[cfg(test)] + pub(crate) frozen: Option, /// Usage reports by library file. usage: BTreeMap, blocked: BTreeSet, @@ -391,6 +394,15 @@ fn write_key(root: &Path) -> Result { const MAX_FIRST_LIBRARY_VERSION: u64 = 1 << 32; impl Store { + /// The time the rate limits are measured at: the clock, or in tests the time they hold it at. + pub(crate) fn clock(&self) -> Instant { + #[cfg(test)] + if let Some(t) = self.frozen { + return t; + } + Instant::now() + } + /// Use the data directory `root` (made if missing). Fails when another server or command holds /// it. Files that can't be read are skipped and listed in [`problems`](Self::problems). pub fn open(root: &Path, cfg: StoreConfig) -> Result { @@ -420,6 +432,8 @@ impl Store { directory, abuse, served: figcraft_collab_net::net::Bucket::new(LIBRARY_RATE, LIBRARY_BURST), + #[cfg(test)] + frozen: None, usage: BTreeMap::new(), blocked: BTreeSet::new(), reports: Vec::new(), diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs index c7aa07e1..ecef744c 100644 --- a/apps/figcraft-server/src/store/backend.rs +++ b/apps/figcraft-server/src/store/backend.rs @@ -1,7 +1,7 @@ use super::journal::flush_log; use super::{ - Access, Action, Admin, AdminReply, Admission, BUSY_ADMIN, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr, - LibraryReply, LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, hash, now, same_secret, + Access, Action, Admin, AdminReply, Admission, BUSY_ADMIN, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, IpAddr, LibraryReply, + LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, hash, now, same_secret, }; /// Whether `req` changes the server's files (a write, a removal or a join code's stretching): @@ -123,7 +123,7 @@ impl Backend for Store { if self.blocked.contains(&ip) { return Err("this server doesn't accept connections from your address".into()); } - if !self.abuse.allow(ip, Action::Connect, Instant::now()) { + if !self.abuse.allow(ip, Action::Connect, self.clock()) { return Err("too many connections from your address lately: wait a minute and try again".into()); } Ok(()) @@ -133,7 +133,7 @@ impl Backend for Store { // Cursors and cursor chat (many people behind one address each send ~30 a second while // they move) have a budget of their own, beside everything else. let what = if matches!(msg, ClientMsg::Presence(_) | ClientMsg::Signal(_) | ClientMsg::Ping) { Action::Presence } else { Action::Message }; - self.abuse.allow(ip, what, Instant::now()) + self.abuse.allow(ip, what, self.clock()) } fn hosting(&mut self, file: &str) -> Option { @@ -186,7 +186,7 @@ impl Backend for Store { fn admin(&mut self, ip: IpAddr, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { let failed = |reason: String| Handled::from(AdminReply::Failed { reason }); - if changes_files(&req) && !self.abuse.allow(ip, Action::Admin, Instant::now()) { + if changes_files(&req) && !self.abuse.allow(ip, Action::Admin, self.clock()) { log::info!("event=refused ip={ip} reason=admin_per_address"); return failed(BUSY_ADMIN.into()); } @@ -253,7 +253,7 @@ impl Backend for Store { // One address's share first (before everyone's budget is spent or anything is read). if let PluginReq::Fetch { id, version } = &req && let Some(size) = self.directory.size_of(id, version.as_deref()) - && !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), Instant::now()) + && !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), self.clock()) { return PluginReply::Failed { reason: BUSY_DOWNLOADS.into() }; } diff --git a/apps/figcraft-server/src/store/files.rs b/apps/figcraft-server/src/store/files.rs index f0d8ddcb..dba35186 100644 --- a/apps/figcraft-server/src/store/files.rs +++ b/apps/figcraft-server/src/store/files.rs @@ -47,7 +47,7 @@ impl Store { if !open { return Err(KEY_ONLY.into()); } - if !self.abuse.allow(ip, Action::Publish, Instant::now()) { + if !self.abuse.allow(ip, Action::Publish, self.clock()) { return Err("too many files published from your address lately: try again in an hour".into()); } let blocks = self.per_block(&ip.to_string()); @@ -340,7 +340,7 @@ impl Store { return Err("say what is wrong with the file".into()); } let name = self.entry(file)?.meta.name.clone(); - if !self.abuse.allow(ip, Action::Report, Instant::now()) { + if !self.abuse.allow(ip, Action::Report, self.clock()) { return Err("too many reports from your address lately: try again in an hour".into()); } self.reports.push(ReportInfo { diff --git a/apps/figcraft-server/src/store/library.rs b/apps/figcraft-server/src/store/library.rs index 2e59e821..4e76bc5f 100644 --- a/apps/figcraft-server/src/store/library.rs +++ b/apps/figcraft-server/src/store/library.rs @@ -1,5 +1,5 @@ use super::{ - Action, BUSY_DOWNLOADS, Cursor, FILES, FileUsage, Instant, IpAddr, LIBRARY, LibraryMeta, LibraryReply, MAX_FIRST_LIBRARY_VERSION, MAX_REPORTED, + Action, BUSY_DOWNLOADS, Cursor, FILES, FileUsage, IpAddr, LIBRARY, LibraryMeta, LibraryReply, MAX_FIRST_LIBRARY_VERSION, MAX_REPORTED, MAX_USAGE_FILE, MAX_USERS, META, NO_PUBLIC_LIBRARY, Path, PathBuf, Store, USAGE, USAGE_WRITE_EVERY, Usage, UsageCache, clean_name, disk, fs, now, write_json, }; @@ -15,7 +15,7 @@ impl Store { } let path = library_file(&dir, lib.version); let size = fs::metadata(&path).map_or(0, |m| m.len()); - let now = Instant::now(); + let now = self.clock(); if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_spread(now, size as f64) { return Err(BUSY_DOWNLOADS.into()); } From db725dc73f3d99b598c12b3d2a8014059df9dd02 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 21:58:41 +0700 Subject: [PATCH 06/10] M8: the Design panel and the selection overlays stop going over a large selection every frame (#1571) (#1660) --- ROADMAP.md | 3 +- crates/ui-egui/src/canvas.rs | 3 + crates/ui-egui/src/canvas/overlays.rs | 28 ++-- .../ui-egui/src/canvas/panel_nodes_tests.rs | 150 ++++++++++++++++++ .../src/canvas/selection_cache_tests.rs | 2 +- crates/ui-egui/src/panels/layout_panel.rs | 8 +- crates/ui-egui/src/panels/right.rs | 47 +++--- crates/ui-egui/src/panels/right/paints.rs | 12 +- crates/ui-egui/src/panels/right/stroke.rs | 2 +- crates/ui-egui/src/panels/section_panel.rs | 2 +- crates/ui-egui/src/selection_cache.rs | 53 ++++++- docs/roadmap.md | 3 +- 12 files changed, 267 insertions(+), 46 deletions(-) create mode 100644 crates/ui-egui/src/canvas/panel_nodes_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index fb471274..8886f7aa 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-30 · **Change:** minor (plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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 @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1571 (part 5, fixes it): with 648 layers selected, the right panel cost 0.55 ms on every frame against 0.19 ms with one layer, and the canvas overlays 0.27 ms against 0.16. Each Design panel section went over the whole selection several times a frame for mixed values and which kinds are selected (about 20 passes, each looking up 648 layers in the document). The overlays did the same for boolean operand outlines and constraint guides. Now the panel looks the selection up once per document snapshot and selection; while a drag only moves the layers, that is the document the drag started from. `common` and a new `every` read those layers. The overlays keep which layers have constraint guides and which boolean operands to outline, worked out once per document and selection; during such a drag, from the start document, since a move changes neither. Measured on the same synthetic file of about 1,900 layers: the drag-all UI mean went from 1.48 to 1.04–1.09 ms per frame, and hovering with all selected from 0.95–1.04 to 0.64–0.69 ms. The right panel went to 0.25 ms and the overlays to 0.18 ms. Screenshots of the panel and the overlays are byte-identical before and after. Tests cover the kept layers after edits, another selection, a deleted layer and another document, and the overlays after structure edits. | | 2026-10-30 | minor | M6, gap 6: plugin panels show pictures. An `image` control carries a PNG, JPEG, WebP, GIF, BMP or TIFF file as base64 (`data`, at most 256 KB; a panel spec may now be 1 MB) and is drawn at its size, at most a field wide and 240 pt tall, never scaled up; screen readers read its `label`. The app decodes it with the thumbnail decoder (its allocation limit), refuses pictures over 4,096 px on a side before decoding them, keeps the last 32 decoded, and shows the label instead of a picture it can't show. Images can't be clicked (`plugin.event` refuses them) and can't be command parameters. Also: a panel's or form's captions no longer wrap ("Palette" broke onto two lines when it was the widest). Numbers unchanged. Tests: `v2_tests`, `tests` (manifest), `plugin_panel_tests::panel_images_show_named_or_fall_back_to_their_label`. | | 2026-10-30 | minor | M6, gap 1: a `.fig` connector with a label but no saved snapshot of it lost the label. It now imports as a connector with a saved snapshot does: a frame holding its line (a vector with the connector's stroke and ends) and the label as text, centred on the middle of the line by length in a 200-wide box, in the connector's fill paints or black. The background the label sits on isn't known without the snapshot and is left out; the import report says so. Checked by rendering a synthetic file with figcraft-cli; not on real files. Numbers unchanged. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label`. | | 2026-10-30 | minor | M8, #1571 (part 4): every move of a drag copies the document from where the drag started and normalises it. The copy was already cheap, since layers are shared, not copied (about 0 ms). Normalising took 1.4 ms per move with 648 layers dragged, because each moved layer went through every step: variable bindings and style links, sizing coherence, text auto-size and the instance sync. Now `normalize` sends a layer whose transform is all that changed, and which is in no component or instance, only through the steps that follow what sits around it: groups refitting, auto layout, connectors, ready-for-dev marks and comment pins. Its bindings (never a position), style links, sizing and text have nothing to follow. This applies to any edit that only moves layers, not just drags. Normalising those 648 moves now takes 0.78 ms. Tests compare the result with a full normalisation for every layer, the top-level layers and every third layer of each example file moved at once, and for a drag of plain, bound, grouped, absolutely placed and component layers, while dragging and after the drop. | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e63caaa3..55a1ef33 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -376,6 +376,9 @@ mod builder_poly_tests; #[cfg(test)] mod outline_tests; +#[cfg(test)] +mod panel_nodes_tests; + #[cfg(test)] mod selection_cache_tests; diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index ad293cb6..e2305769 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -57,7 +57,12 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec } // Inside a boolean group (an operand selected or in vector edit mode): every operand's outline, // so the shapes making the live result stay visible while one of them changes. - let operands = figcraft_engine::booleans::outlined_operands(&doc, &sel, ds.vector_edit.as_ref().map(|ve| ve.id)); + let editing = ds.vector_edit.as_ref().map(|ve| ve.id); + let unmoved = crate::selection_cache::unmoved(ds, &doc); + let key: Vec = editing.map(|e| e.0).into_iter().collect(); + let operands = crate::selection_cache::cached(painter.ctx(), "operands", &unmoved, &sel, &key, || { + figcraft_engine::booleans::outlined_operands(&unmoved, &sel, editing) + }); for id in operands.iter().filter(|o| !sel.contains(o)) { draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id, accent(painter)).gamma_multiply(0.6), 1.0)); } @@ -310,7 +315,7 @@ fn selection_overlay( // Constraint guides belong to the normal selection overlay: not while the Shape Builder // reshapes by regions, nor while measuring to another layer (Alt), whose lines take over. if app.session.tool.tool != ToolId::ShapeBuilder && app.session.tool.overlay.measure_box.is_none() { - constraint_lines(app, painter, doc, sel); + constraint_lines(app, painter, ds, doc, sel); } // The shape builder reshapes by regions: no transform box. if app.session.tool.tool != ToolId::ShapeBuilder @@ -494,7 +499,17 @@ fn transform_box( /// short bar through the layer's middle — a horizontal one, a vertical one, or both (a cross when /// the layer is centred on both axes). Auto-layout frames ignore constraints, so no guides are /// drawn there, except for a layer that ignores the auto layout (absolute position). -fn constraint_lines(app: &FigApp, painter: &egui::Painter, doc: &Document, sel: &[NodeId]) { +fn constraint_lines(app: &FigApp, painter: &egui::Painter, ds: &figcraft_engine::DocState, doc: &Arc, sel: &[NodeId]) { + // Constraints bind a layer only to a frame it sits inside: one without auto layout, or one + // whose auto layout the layer ignores (absolute position). A move changes neither. + let unmoved = crate::selection_cache::unmoved(ds, doc); + let bound = crate::selection_cache::cached(painter.ctx(), "constrained", &unmoved, sel, &[], || -> Vec { + let binds = |n: &figcraft_doc::Node, p: &figcraft_doc::Node| p.kind.is_frame_like() && (p.layout.is_none() || n.child_layout.absolute); + sel.iter() + .copied() + .filter(|i| unmoved.node(*i).is_some_and(|n| n.parent.and_then(|p| unmoved.node(p)).is_some_and(|p| binds(n, p)))) + .collect() + }); let s = stroke(accent(painter), 1.0); let line = |a: Pos2, b: Pos2| { painter.add(Shape::dashed_line(&[a, b], s, 4.0, 3.0)); @@ -508,15 +523,10 @@ fn constraint_lines(app: &FigApp, painter: &egui::Painter, doc: &Document, sel: painter.line_segment([pos2(c.x, c.y - half), pos2(c.x, c.y + half)], s); } }; - for &id in sel { + for &id in &bound { let Some(n) = doc.node(id) else { continue }; let Some(parent_id) = n.parent else { continue }; let Some(parent) = doc.node(parent_id) else { continue }; - // Constraints bind a layer only to a frame it sits inside: one without auto layout, or one - // whose auto layout the layer ignores (absolute position). - if !parent.kind.is_frame_like() || (parent.layout.is_some() && !n.child_layout.absolute) { - continue; - } let nw = doc.world_transform(id); let pw = doc.world_transform(parent_id); let (nw_w, nw_h) = (n.width, n.height); diff --git a/crates/ui-egui/src/canvas/panel_nodes_tests.rs b/crates/ui-egui/src/canvas/panel_nodes_tests.rs new file mode 100644 index 00000000..cd38e756 --- /dev/null +++ b/crates/ui-egui/src/canvas/panel_nodes_tests.rs @@ -0,0 +1,150 @@ +//! What a large selection's frames keep (#1571): the Design panel looks the selected layers up once +//! per document snapshot and selection, and the canvas keeps which layers its constraint guides and +//! boolean operand outlines go with. Each answers as the document does, after edits, another +//! selection, a deleted layer, and for any other document. + +use std::sync::Arc; + +use egui::{Color32, Context, Rect, pos2, vec2}; +use figcraft_doc::{Document, NodeId, NodeKind}; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use super::selection_cache_tests::drawn; +use crate::right::common; +use crate::selection_cache::{every, show}; +use crate::theme::set_canvas_page; +use crate::{FigApp, NoServices}; + +fn id(v: Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: i32, parent: Option) -> NodeId { + let mut p = json!({"x": x, "y": 10, "width": 40, "height": 40}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id(s.execute("shape.rectangle", &p).unwrap()) +} + +/// The panel's questions (the selection's common opacity, whether it is all rectangles), asked as +/// the panel asks them. +fn answers(doc: &Document, sel: &[NodeId]) -> (Option, bool) { + (common(doc, sel, |n| n.opacity.to_bits()), every(doc, sel, |n| matches!(n.kind, NodeKind::Rectangle))) +} + +/// The same questions asked of the document directly. +fn direct(doc: &Document, sel: &[NodeId]) -> (Option, bool) { + let opacities: Vec = sel.iter().filter_map(|i| doc.node(*i)).map(|n| n.opacity.to_bits()).collect(); + let common = opacities.first().copied().filter(|o| opacities.iter().all(|x| x == o)); + (common, sel.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Rectangle)))) +} + +#[test] +fn the_panels_kept_layers_answer_as_the_document_does() { + let mut s = Session::new(); + let (a, b) = (rect(&mut s, 0, None), rect(&mut s, 80, None)); + s.execute("node.set", &json!({"ids": [a], "props": {"opacity": 0.5}})).unwrap(); + let sel = vec![a, b]; + let doc = s.ds().unwrap().doc.clone(); + show(&doc, &sel); + assert_eq!(answers(&doc, &sel), direct(&doc, &sel)); + assert_eq!(answers(&doc, &sel).0, None, "mixed"); + // An edit is a new snapshot: answered from itself, before it is shown and after. + s.execute("node.set", &json!({"ids": [a, b], "props": {"opacity": 0.25}})).unwrap(); + let edited = s.ds().unwrap().doc.clone(); + assert!(!Arc::ptr_eq(&doc, &edited)); + assert_eq!(answers(&edited, &sel), (Some(0.25f32.to_bits()), true)); + show(&edited, &sel); + assert_eq!(answers(&edited, &sel), (Some(0.25f32.to_bits()), true)); + assert_eq!(answers(&doc, &sel), direct(&doc, &sel), "the document shown before still answers for itself"); + // Another selection. + s.execute("node.set", &json!({"ids": [b], "props": {"opacity": 1.0}})).unwrap(); + let doc = s.ds().unwrap().doc.clone(); + show(&doc, &sel); + for ids in [vec![a], vec![b], vec![b, a]] { + assert_eq!(answers(&doc, &ids), direct(&doc, &ids), "{ids:?}"); + show(&doc, &ids); + assert_eq!(answers(&doc, &ids), direct(&doc, &ids), "{ids:?} shown"); + } + // A layer that isn't in the document: `every` says no, `common` goes by the others. + s.execute("edit.delete", &json!({"ids": [b]})).unwrap(); + let doc = s.ds().unwrap().doc.clone(); + show(&doc, &sel); + assert_eq!(answers(&doc, &sel), (Some(0.25f32.to_bits()), false)); +} + +#[test] +fn a_kept_selection_never_answers_for_another_document() { + let mut s = Session::new(); + let a = rect(&mut s, 0, None); + let sel = vec![a]; + let base = s.doc().unwrap().clone(); + let first = Arc::new(base.clone()); + let kept_at = Arc::as_ptr(&first); + show(&first, &sel); + drop(first); + let mut changed = base; + changed.node_mut(a).unwrap().opacity = 0.1; + let second = Arc::new(changed); + assert_ne!(Arc::as_ptr(&second), kept_at, "the kept document's place stays taken while it is kept"); + assert_eq!(answers(&second, &sel), (Some(0.1f32.to_bits()), true)); +} + +fn app_of(s: Session) -> (FigApp, Context) { + let mut app = FigApp::new(s, Box::new(NoServices)); + app.canvas.rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let ctx = Context::default(); + set_canvas_page(&ctx, Color32::WHITE); + (app, ctx) +} + +/// What one frame draws, and what it draws with nothing kept. +fn drawn_now(app: &FigApp, ctx: &Context) -> (usize, usize) { + let fresh = Context::default(); + set_canvas_page(&fresh, Color32::WHITE); + (drawn(app, ctx), drawn(app, &fresh)) +} + +#[test] +fn constraint_guides_follow_the_layers_frame() { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 300})).unwrap()); + let r = rect(&mut s, 40, Some(f)); + let page = s.page().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let (mut app, ctx) = app_of(s); + let (pinned, fresh) = drawn_now(&app, &ctx); + assert_eq!(pinned, fresh); + for (parent, what) in [(page, "on the page: no frame to pin it to"), (f, "back in its frame")] { + app.session.execute("node.reparent", &json!({"ids": [r], "parent": parent})).unwrap(); + app.session.execute("select.set", &json!({"ids": [r]})).unwrap(); + let (now, fresh) = drawn_now(&app, &ctx); + assert_eq!(now, fresh, "{what}"); + assert_eq!(now < pinned, parent == page, "{what}"); + } + // In an auto layout flow its constraints don't apply; ignoring the layout, they do. + app.session.execute("layout.add", &json!({"ids": [f]})).unwrap(); + let (placed, fresh) = drawn_now(&app, &ctx); + assert_eq!(placed, fresh, "auto layout places the layer"); + app.set_props(&[r], json!({"absolute": true})); + let (absolute, fresh) = drawn_now(&app, &ctx); + assert_eq!(absolute, fresh, "ignoring the auto layout"); +} + +#[test] +fn operand_outlines_follow_the_boolean_group() { + let mut s = Session::new(); + let ops = [rect(&mut s, 0, None), rect(&mut s, 20, None), rect(&mut s, 40, None)]; + s.execute("bool.union", &json!({"ids": ops})).unwrap(); + s.execute("select.set", &json!({"ids": [ops[0]]})).unwrap(); + let (mut app, ctx) = app_of(s); + let (three, fresh) = drawn_now(&app, &ctx); + assert_eq!(three, fresh); + app.session.execute("edit.delete", &json!({"ids": [ops[2]]})).unwrap(); + app.session.execute("select.set", &json!({"ids": [ops[0]]})).unwrap(); + let (two, fresh) = drawn_now(&app, &ctx); + assert_eq!(two, fresh, "one operand fewer to outline"); + assert!(two < three); +} diff --git a/crates/ui-egui/src/canvas/selection_cache_tests.rs b/crates/ui-egui/src/canvas/selection_cache_tests.rs index 49427ce2..061fe5b5 100644 --- a/crates/ui-egui/src/canvas/selection_cache_tests.rs +++ b/crates/ui-egui/src/canvas/selection_cache_tests.rs @@ -112,7 +112,7 @@ fn every_input_the_geometry_shows_makes_it_again() { } /// How many shapes one frame of the canvas overlays draws. -fn drawn(app: &FigApp, ctx: &Context) -> usize { +pub(super) fn drawn(app: &FigApp, ctx: &Context) -> usize { let mut out = ctx.run_ui(Default::default(), |ui| { overlays(app, &ui.ctx().layer_painter(LayerId::background()), &Tokens::light()); }); diff --git a/crates/ui-egui/src/panels/layout_panel.rs b/crates/ui-egui/src/panels/layout_panel.rs index 117715d8..4762bdd0 100644 --- a/crates/ui-egui/src/panels/layout_panel.rs +++ b/crates/ui-egui/src/panels/layout_panel.rs @@ -29,9 +29,9 @@ fn parent_of(doc: &Document, id: NodeId) -> Option<&Node> { /// no auto layout and the selection is editable outside an instance. pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, sel: &[NodeId]) { let ids = sel.to_vec(); - let frames = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())); - let layout_containers = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Group))); - let no_al = !sel.is_empty() && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.layout.is_none())); + let frames = crate::selection_cache::every(doc, sel, |n| n.kind.is_frame_like()); + let layout_containers = crate::selection_cache::every(doc, sel, |n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Group)); + let no_al = !sel.is_empty() && crate::selection_cache::every(doc, sel, |n| n.layout.is_none()); let al = common(doc, sel, |n| n.layout.clone()); let has_al = matches!(al, Some(Some(_))); let title = if has_al { tr("Auto layout") } else { tr("Layout") }; @@ -187,7 +187,7 @@ fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has return; } // Min/max apply to auto layout frames and their children; absolute children neither fill nor limit. - let can_fill = parent_al && sel.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute)); + let can_fill = parent_al && crate::selection_cache::every(doc, sel, |n| !n.child_layout.absolute); let can_limit = has_al || can_fill; let first = sel.first().and_then(|i| doc.node(*i)); row(ui, 32.0, |ui| { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 58a1a6e1..6dde1313 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -42,9 +42,11 @@ pub(crate) use stroke::{CAP_ART_W, cap_art, join_label}; /// Common value of `f` across `ids` (None when mixed or empty). pub(crate) fn common(doc: &Document, ids: &[NodeId], f: impl Fn(&Node) -> T) -> Option { - let mut it = ids.iter().filter_map(|i| doc.node(*i)); - let first = f(it.next()?); - if it.all(|n| f(n) == first) { Some(first) } else { None } + crate::selection_cache::with_nodes(doc, ids, |it| { + let mut it = it.flatten(); + let first = f(it.next()?); + if it.all(|n| f(n) == first) { Some(first) } else { None } + }) } impl FigApp { @@ -257,6 +259,7 @@ fn design(app: &mut FigApp, ui: &mut Ui) { page_section(app, ui, &doc); return; } + crate::selection_cache::show(&shown, &sel); header(app, ui, &shown, &sel); widgets::separator(ui); // Editing a vector with points selected, the points' fields stand in for the layer's position @@ -287,7 +290,7 @@ fn design(app: &mut FigApp, ui: &mut Ui) { if variable_modes(app, ui, &shown, &sel) { widgets::separator(ui); } - if sel.iter().any(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))) { + if crate::selection_cache::with_nodes(&shown, &sel, |it| it.flatten().any(|n| matches!(n.kind, NodeKind::Text(_)))) { crate::typography::typography(app, ui, &shown, &sel); widgets::separator(ui); } @@ -298,7 +301,7 @@ fn design(app: &mut FigApp, ui: &mut Ui) { effects(app, ui, &shown, &sel); widgets::separator(ui); crate::selection_colors::section(app, ui, &shown, &sel); - if sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())) { + if crate::selection_cache::every(&shown, &sel, |n| n.kind.is_frame_like()) { grids(app, ui, &shown, &sel); widgets::separator(ui); } @@ -376,7 +379,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { }; let is_frame = sel.len() == 1 && sel.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Frame)); // Frames and groups convert into each other from the type menu. - let is_group = !sel.is_empty() && sel.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Group))); + let is_group = !sel.is_empty() && crate::selection_cache::every(doc, sel, |n| matches!(n.kind, NodeKind::Group)); let kind_items = |app: &mut FigApp, ui: &mut Ui, frame: bool| { for (to_frame, label, cmd) in [(true, tr("Frame"), "object.convertToFrame"), (false, tr("Group"), "object.convertToGroup")] { if menu_check(ui, to_frame == frame, label, "") { @@ -718,13 +721,14 @@ fn constraints_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], c: Option bool { - let arcs: Vec<(f64, f64, f64)> = sel - .iter() - .filter_map(|i| match doc.node(*i)?.kind { - NodeKind::Ellipse { start, sweep, inner } => Some((start, sweep, inner)), - _ => None, - }) - .collect(); + let arcs: Vec<(f64, f64, f64)> = crate::selection_cache::with_nodes(doc, sel, |it| { + it.flatten() + .filter_map(|n| match n.kind { + NodeKind::Ellipse { start, sweep, inner } => Some((start, sweep, inner)), + _ => None, + }) + .collect() + }); if arcs.is_empty() || arcs.len() != sel.len() { return false; } @@ -761,14 +765,15 @@ fn arc_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) -> /// inner radius as a share of the outer one), as `node.set`'s `pointCount` / `innerRadius`. /// Returns whether it showed. fn points_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) -> bool { - let shapes: Vec<(u32, Option)> = sel - .iter() - .filter_map(|i| match doc.node(*i)?.kind { - NodeKind::Polygon { count } => Some((count, None)), - NodeKind::Star { count, inner_ratio } => Some((count, Some(inner_ratio))), - _ => None, - }) - .collect(); + let shapes: Vec<(u32, Option)> = crate::selection_cache::with_nodes(doc, sel, |it| { + it.flatten() + .filter_map(|n| match n.kind { + NodeKind::Polygon { count } => Some((count, None)), + NodeKind::Star { count, inner_ratio } => Some((count, Some(inner_ratio))), + _ => None, + }) + .collect() + }); if shapes.is_empty() || shapes.len() != sel.len() { return false; } diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index 11b368d1..58423d25 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -33,7 +33,7 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N let col = widgets::col_width(ui.available_width()); crate::variable_ui::bound_row(app, ui, &ids, "visible", VariableType::Boolean, Lead::Icon(Icon::Eye, tr("Visible")), col * 2.0 + 8.0); // Masks: how they mask. - if sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.is_mask)) { + if crate::selection_cache::every(doc, sel, |n| n.is_mask) { let mt = common(doc, sel, |n| n.mask_type); widgets::sublabels(ui, tr("Mask"), None); row(ui, 32.0, |ui| { @@ -50,11 +50,11 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N } let op_of = |n: &Node| (f64::from(n.opacity) * 100.0).round(); let op = common(doc, sel, op_of); - let radius_ok = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_radius())); + let radius_ok = crate::selection_cache::every(doc, sel, |n| n.kind.has_corner_radius()); // Only rectangles and frames round each corner on its own. - let indep_ok = radius_ok && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_independent_corners())); + let indep_ok = radius_ok && crate::selection_cache::every(doc, sel, |n| n.kind.has_independent_corners()); // For polygons and stars the same panel holds only the Corner smoothing row. - let smooth_ok = radius_ok && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_smoothing())); + let smooth_ok = radius_ok && crate::selection_cache::every(doc, sel, |n| n.kind.has_corner_smoothing()); let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0)); widgets::sublabels(ui, tr("Opacity"), radius_ok.then_some(tr("Corner radius"))); row(ui, 32.0, |ui| { @@ -147,7 +147,7 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N /// of its own (`variable.setMode`). Collections from enabled libraries follow under their /// library's name (`library.setVariableMode` copies the collection in). Returns whether it showed. pub(super) fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) -> bool { - if !sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())) { + if !crate::selection_cache::every(doc, sel, |n| n.kind.is_frame_like()) { return false; } let libs = crate::variable_ui::libraries(app, doc); @@ -351,7 +351,7 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI // The row is the panel's width: the two fields split what the buttons at its end leave, // so the align field and the weight field are the same column (76 at the default width // with two buttons, 88 with one). The buttons are 24 pt each, 4 apart on the icon grid. - let sided = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle))); + let sided = crate::selection_cache::every(doc, sel, |n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle)); let group = if sided { 24.0 + 4.0 + 24.0 } else { 24.0 }; let field_w = ((ui.available_width() - widgets::GUTTER * 2.0 - group) / 2.0).max(40.0); let resp = widgets::dropdown( diff --git a/crates/ui-egui/src/panels/right/stroke.rs b/crates/ui-egui/src/panels/right/stroke.rs index e7ebbcd8..ef7a3648 100644 --- a/crates/ui-egui/src/panels/right/stroke.rs +++ b/crates/ui-egui/src/panels/right/stroke.rs @@ -240,7 +240,7 @@ pub(super) fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel } }); // End points only mean something on open paths. - let open_paths = sel.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Line | NodeKind::Vector { .. }))); + let open_paths = crate::selection_cache::every(doc, sel, |n| matches!(n.kind, NodeKind::Line | NodeKind::Vector { .. })); if open_paths { widgets::sublabels(ui, tr("Start point"), Some(tr("End point"))); row(ui, 32.0, |ui| { diff --git a/crates/ui-egui/src/panels/section_panel.rs b/crates/ui-egui/src/panels/section_panel.rs index daa793db..4ea14692 100644 --- a/crates/ui-egui/src/panels/section_panel.rs +++ b/crates/ui-egui/src/panels/section_panel.rs @@ -107,7 +107,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } }); row(ui, 28.0, |ui| { - let hidden = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.hides_contents())); + let hidden = crate::selection_cache::every(doc, sel, |n| n.hides_contents()); if widgets::checkbox(ui, hidden, tr("Hide contents")) { app.run("section.toggleContents", json!({"ids": ids, "hidden": !hidden})); } diff --git a/crates/ui-egui/src/selection_cache.rs b/crates/ui-egui/src/selection_cache.rs index e799c48b..2bf0f867 100644 --- a/crates/ui-egui/src/selection_cache.rs +++ b/crates/ui-egui/src/selection_cache.rs @@ -7,7 +7,7 @@ use std::sync::{Arc, Weak}; -use figcraft_doc::{Document, NodeId}; +use figcraft_doc::{Document, Node, NodeId}; /// One kept value and what it was made from. The document is held weakly, so a kept value never /// keeps an old snapshot alive; the weak reference keeps its address from being reused. @@ -52,6 +52,57 @@ pub(crate) fn moved(ds: &figcraft_engine::DocState) -> Option<(Arc, (f (it.selection == ds.selection).then(|| (it.doc.clone(), d)) } +/// The document to work out from what a move doesn't change (which layers, their kinds and +/// parents): the one a gesture that only moved the selection started from, kept for the whole +/// gesture ([`moved`]), else `doc`. +pub(crate) fn unmoved(ds: &figcraft_engine::DocState, doc: &Arc) -> Arc { + moved(ds).map_or_else(|| doc.clone(), |(base, _)| base) +} + +/// The selected layers of the document the Design panel shows, looked up once: its sections go +/// over the whole selection many times a frame (mixed values, which kinds are selected), and with +/// hundreds of layers selected the lookups cost more than the rest of the panel (#1571). +struct Shown { + doc: Weak, + sel: Vec, + nodes: Vec>>, +} + +thread_local! { + static SHOWN: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// The panel is about to show `sel` of `doc`: [`with_nodes`] over exactly these reads the layers +/// looked up here, kept while the document snapshot and the selection stay the same. +pub(crate) fn show(doc: &Arc, sel: &[NodeId]) { + SHOWN.with(|s| { + let mut s = s.borrow_mut(); + if s.as_ref().is_some_and(|k| std::ptr::eq(k.doc.as_ptr(), Arc::as_ptr(doc)) && k.sel == sel) { + return; + } + *s = Some(Shown { doc: Arc::downgrade(doc), sel: sel.to_vec(), nodes: sel.iter().map(|i| doc.node_arc(*i).cloned()).collect() }); + }); +} + +/// `f` over the layers of `ids` in `doc`, in order (`None` for one not in it): the ones [`show`] +/// kept when `doc` and `ids` are what it was given, else looked up. The kept document is held +/// weakly, so its address can't be another document's and its contents can't change. +pub(crate) fn with_nodes(doc: &Document, ids: &[NodeId], f: impl FnOnce(&mut dyn Iterator>) -> R) -> R { + SHOWN.with(|s| { + let s = s.borrow(); + match s.as_ref().filter(|k| std::ptr::eq(k.doc.as_ptr(), doc) && k.sel == ids) { + Some(k) => f(&mut k.nodes.iter().map(Option::as_deref)), + None => f(&mut ids.iter().map(|i| doc.node(*i))), + } + }) +} + +/// Whether every one of `ids` is in `doc` and passes `f`. +pub(crate) fn every(doc: &Document, ids: &[NodeId], f: impl Fn(&Node) -> bool) -> bool { + // `Iterator::all` on the `&mut dyn Iterator` itself (sized), not on the trait object. + with_nodes(doc, ids, |mut it| Iterator::all(&mut it, |n| n.is_some_and(&f))) +} + /// A screen point as key material. pub(crate) fn bits(p: egui::Pos2) -> [u64; 2] { [u64::from(p.x.to_bits()), u64::from(p.y.to_bits())] diff --git a/docs/roadmap.md b/docs/roadmap.md index 55553f9d..b2ee3980 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-30 · **Change:** minor (plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); 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 @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1571 (part 5, fixes it): with 648 layers selected, the right panel cost 0.55 ms on every frame against 0.19 ms with one layer, and the canvas overlays 0.27 ms against 0.16. Each Design panel section went over the whole selection several times a frame for mixed values and which kinds are selected (about 20 passes, each looking up 648 layers in the document). The overlays did the same for boolean operand outlines and constraint guides. Now the panel looks the selection up once per document snapshot and selection; while a drag only moves the layers, that is the document the drag started from. `common` and a new `every` read those layers. The overlays keep which layers have constraint guides and which boolean operands to outline, worked out once per document and selection; during such a drag, from the start document, since a move changes neither. Measured on the same synthetic file of about 1,900 layers: the drag-all UI mean went from 1.48 to 1.04–1.09 ms per frame, and hovering with all selected from 0.95–1.04 to 0.64–0.69 ms. The right panel went to 0.25 ms and the overlays to 0.18 ms. Screenshots of the panel and the overlays are byte-identical before and after. Tests cover the kept layers after edits, another selection, a deleted layer and another document, and the overlays after structure edits. | | 2026-10-30 | minor | M6, gap 6: `image` controls in plugin panels (base64, 256 KB, 4,096 px a side; specs up to 1 MB), named by their label. Tests: `v2_tests`, `plugin_panel_tests` | | 2026-10-30 | minor | M6, gap 1: `.fig` connector labels without a saved snapshot are kept, centred on the line, instead of skipped. Test: `fig::tests::tail` | | 2026-10-30 | minor | M8, #1571 (part 4): every move of a drag copies the document from where the drag started and normalises it. The copy was already cheap, since layers are shared, not copied (about 0 ms). Normalising took 1.4 ms per move with 648 layers dragged, because each moved layer went through every step: variable bindings and style links, sizing coherence, text auto-size and the instance sync. Now `normalize` sends a layer whose transform is all that changed, and which is in no component or instance, only through the steps that follow what sits around it: groups refitting, auto layout, connectors, ready-for-dev marks and comment pins. Its bindings (never a position), style links, sizing and text have nothing to follow. This applies to any edit that only moves layers, not just drags. Normalising those 648 moves now takes 0.78 ms. Tests compare the result with a full normalisation for every layer, the top-level layers and every third layer of each example file moved at once, and for a drag of plain, bound, grouped, absolutely placed and component layers, while dragging and after the drop. | From ddda495a6ff40c326a117fbc393a8cfaa319f529 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 22:12:49 +0700 Subject: [PATCH 07/10] M6: a long frame name stays on one line in the Export button and Dev Mode's header (#1650) (#1661) --- ROADMAP.md | 3 +- crates/ui-egui/src/export_name_tests.rs | 134 +++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/export_panel.rs | 6 +- crates/ui-egui/src/panels/right/inspect.rs | 9 +- crates/ui-egui/src/widgets.rs | 35 +++--- docs/roadmap.md | 3 +- 7 files changed, 172 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/export_name_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8886f7aa..958c5d1b 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-30 · **Change:** minor (with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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 @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, #1650: a very long frame name made the Export section's **Export <name>** button wrap its label into a tall block centred on the button, painted over Position, Layout, Appearance, Fill and the rest of the Design panel. The button now shows one line ending with an ellipsis, with the full name on hover, and line breaks in a name show as spaces. Dev Mode's header, which names the layer, does the same instead of running off the panel's edge. Shortening text to fit now halves its way to the longest fit instead of trying one candidate per character: with a 4,000-character name that kept frames at about 0.4 ms instead of about 9.5 ms, and it finds exactly what the old search found. | | 2026-10-30 | minor | M8, #1571 (part 5, fixes it): with 648 layers selected, the right panel cost 0.55 ms on every frame against 0.19 ms with one layer, and the canvas overlays 0.27 ms against 0.16. Each Design panel section went over the whole selection several times a frame for mixed values and which kinds are selected (about 20 passes, each looking up 648 layers in the document). The overlays did the same for boolean operand outlines and constraint guides. Now the panel looks the selection up once per document snapshot and selection; while a drag only moves the layers, that is the document the drag started from. `common` and a new `every` read those layers. The overlays keep which layers have constraint guides and which boolean operands to outline, worked out once per document and selection; during such a drag, from the start document, since a move changes neither. Measured on the same synthetic file of about 1,900 layers: the drag-all UI mean went from 1.48 to 1.04–1.09 ms per frame, and hovering with all selected from 0.95–1.04 to 0.64–0.69 ms. The right panel went to 0.25 ms and the overlays to 0.18 ms. Screenshots of the panel and the overlays are byte-identical before and after. Tests cover the kept layers after edits, another selection, a deleted layer and another document, and the overlays after structure edits. | | 2026-10-30 | minor | M6, gap 6: plugin panels show pictures. An `image` control carries a PNG, JPEG, WebP, GIF, BMP or TIFF file as base64 (`data`, at most 256 KB; a panel spec may now be 1 MB) and is drawn at its size, at most a field wide and 240 pt tall, never scaled up; screen readers read its `label`. The app decodes it with the thumbnail decoder (its allocation limit), refuses pictures over 4,096 px on a side before decoding them, keeps the last 32 decoded, and shows the label instead of a picture it can't show. Images can't be clicked (`plugin.event` refuses them) and can't be command parameters. Also: a panel's or form's captions no longer wrap ("Palette" broke onto two lines when it was the widest). Numbers unchanged. Tests: `v2_tests`, `tests` (manifest), `plugin_panel_tests::panel_images_show_named_or_fall_back_to_their_label`. | | 2026-10-30 | minor | M6, gap 1: a `.fig` connector with a label but no saved snapshot of it lost the label. It now imports as a connector with a saved snapshot does: a frame holding its line (a vector with the connector's stroke and ends) and the label as text, centred on the middle of the line by length in a 200-wide box, in the connector's fill paints or black. The background the label sits on isn't known without the snapshot and is left out; the import report says so. Checked by rendering a synthetic file with figcraft-cli; not on real files. Numbers unchanged. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label`. | diff --git a/crates/ui-egui/src/export_name_tests.rs b/crates/ui-egui/src/export_name_tests.rs new file mode 100644 index 00000000..3f85374a --- /dev/null +++ b/crates/ui-egui/src/export_name_tests.rs @@ -0,0 +1,134 @@ +//! The Export section's button names the layer it exports on one line inside the button: a long +//! name ends with an ellipsis instead of wrapping into a block painted over the rest of the panel +//! (#1650), at the default panel width and wider. + +use egui::{Rect, Shape}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::widgets::FIELD_H; + +/// The text painted this frame, with each piece's box. +fn painted(u: &mut Ui) -> Vec<(String, Rect)> { + fn walk(shape: &Shape, out: &mut Vec<(String, Rect)>) { + match shape { + Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), + Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + u.frame(vec![]); + let frame = u.frame_output(vec![]); + let mut out = Vec::new(); + for c in &frame.shapes { + walk(&c.shape, &mut out); + } + out +} + +/// A frame named `name` with an export setting, selected, in a right panel `width` wide: the export +/// button's label and box, and the panel's box. +fn export_label(name: &str, width: f32) -> (String, Rect, Rect) { + let mut s = Session::new(); + let f = crate::present_tests::id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 856, "height": 1089})).unwrap()); + s.execute("export.add", &json!({"ids": [f]})).unwrap(); + s.execute("node.set", &json!({"id": f, "props": {"name": name}})).unwrap(); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.right_width = width; + let texts = painted(&mut u); + let panel = u.app.ui.right_panel_rect.expect("the right panel shows"); + assert!((panel.width() - width).abs() <= 1.0, "a {width} px panel (less its edge), not {panel:?}"); + let buttons: Vec<(String, Rect)> = texts.into_iter().filter(|(t, r)| t.starts_with("Export ") && r.min.x >= panel.min.x).collect(); + let [(text, rect)] = buttons.as_slice() else { panic!("one export button: {buttons:?}") }; + assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().name, name, "the name itself stays whole"); + (text.clone(), *rect, panel) +} + +#[test] +fn a_long_name_stays_on_one_line_inside_the_export_button() { + let long = "FRAME_NAME_OVERFLOW_".repeat(200); + for width in [240.0, 400.0] { + let (text, rect, panel) = export_label(&long, width); + assert!(text.ends_with('…'), "{width}: shortened, not {} characters", text.chars().count()); + assert!(rect.min.x >= panel.min.x && rect.max.x <= panel.max.x, "{width}: {rect:?} inside {panel:?}"); + assert!(rect.height() <= FIELD_H, "{width}: one line, not {} tall", rect.height()); + } +} + +/// Dev Mode's header names the layer on one line inside the panel too. +#[test] +fn dev_modes_header_shortens_a_long_name() { + let long = "FRAME_NAME_OVERFLOW_".repeat(200); + let mut s = Session::new(); + let f = crate::present_tests::id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 856, "height": 1089})).unwrap()); + s.execute("node.set", &json!({"id": f, "props": {"name": long}})).unwrap(); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + let texts = painted(&mut u); + let panel = u.app.ui.right_panel_rect.expect("the right panel shows"); + let names: Vec<(String, Rect)> = texts.into_iter().filter(|(t, r)| t.starts_with("FRAME_NAME") && r.min.x >= panel.min.x).collect(); + assert!(!names.is_empty(), "the header names the layer"); + for (text, rect) in names { + assert!(text.ends_with('…'), "shortened: {} characters", text.chars().count()); + assert!(rect.max.x <= panel.max.x, "{rect:?} inside {panel:?}"); + } +} + +/// Eliding as it was done before: every candidate from the longest down, the first that fits. +fn elide_by_trying_all(ui: &egui::Ui, s: &str, max_w: f32, font: egui::FontId, from_start: bool) -> String { + let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), egui::Color32::WHITE).size().x <= max_w; + if fits(s) { + return s.to_string(); + } + let chars: Vec = s.chars().collect(); + (1..chars.len()) + .rev() + .map(|n| { + if from_start { + format!("…{}", chars[chars.len() - n..].iter().collect::().trim_start()) + } else { + format!("{}…", chars.iter().take(n).collect::().trim_end()) + } + }) + .find(|c| fits(c)) + .unwrap_or_else(|| "…".into()) +} + +/// Eliding halves its way to the longest candidate that fits (a long name no longer lays out one +/// candidate per character every frame) and finds what trying every candidate finds. +#[test] +fn eliding_finds_what_trying_every_candidate_finds() { + use crate::theme::{regular, semibold}; + let texts = [ + "FRAME_NAME_OVERFLOW_".repeat(20), + "A name with spaces in it".to_string(), + "W".repeat(40), + "i".repeat(60), + "Ünïcödé names — with “quotes” and 漢字 too".to_string(), + ]; + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(Default::default(), |ui| { + for s in &texts { + for w in [0.0, 5.0, 12.0, 40.0, 97.5, 160.0, 400.0, 2000.0] { + for font in [regular(11.0), semibold(13.0)] { + let end = crate::widgets::elide_font(ui, s, w, font.clone()); + assert_eq!(end, elide_by_trying_all(ui, s, w, font.clone(), false), "{s:?} in {w}"); + let start = crate::widgets::elide_start_font(ui, s, w, font.clone()); + assert_eq!(start, elide_by_trying_all(ui, s, w, font, true), "{s:?} from its start in {w}"); + } + } + } + }); + out.textures_delta.clear(); +} + +#[test] +fn line_breaks_in_a_name_show_as_spaces_and_short_names_in_full() { + let (text, rect, _) = export_label("First line\nsecond line", 240.0); + assert_eq!(text, "Export First line second line"); + assert!(rect.height() <= FIELD_H); + assert_eq!(export_label("Frame 1", 240.0).0, "Export Frame 1"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 751f01f1..658b1d45 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -98,6 +98,8 @@ mod display_name_tests; #[cfg(test)] mod duplicate_paint_tests; #[cfg(test)] +mod export_name_tests; +#[cfg(test)] mod export_preview_tests; #[cfg(test)] mod field_tests; diff --git a/crates/ui-egui/src/panels/export_panel.rs b/crates/ui-egui/src/panels/export_panel.rs index 6f46ac75..14b5a890 100644 --- a/crates/ui-egui/src/panels/export_panel.rs +++ b/crates/ui-egui/src/panels/export_panel.rs @@ -362,8 +362,12 @@ pub(crate) fn wide_button(ui: &mut Ui, label: &str) -> bool { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, t.border_strong), StrokeKind::Inside); - let galley = ui.painter().layout(label.to_string(), regular(11.0), t.text, w - 16.0); + // One line inside the button: a long layer name ends with an ellipsis and shows in full on + // hover (#1650). + let shown = widgets::elide(ui, &label.replace(['\r', '\n'], " "), w - 16.0); + let galley = ui.painter().layout_no_wrap(shown.clone(), regular(11.0), t.text); ui.painter().galley(r.center() - galley.size() / 2.0, galley, t.text); + let resp = if shown == label { resp } else { resp.on_hover_text(label) }; clicked = resp.clicked(); }); clicked diff --git a/crates/ui-egui/src/panels/right/inspect.rs b/crates/ui-egui/src/panels/right/inspect.rs index 2d558344..711b9a99 100644 --- a/crates/ui-egui/src/panels/right/inspect.rs +++ b/crates/ui-egui/src/panels/right/inspect.rs @@ -195,8 +195,13 @@ pub(super) fn inspect(app: &mut FigApp, ui: &mut Ui) { crate::dev_mode::plugins_section(app, ui); return; }; - let name = doc.node(first).map(|n| n.name.clone()).unwrap_or_default(); - widgets::section_header(ui, &name, &[], false); + // The layer's name on one line: a long one ends with an ellipsis and shows in full on hover (#1650). + let name = doc.node(first).map(|n| n.name.replace(['\r', '\n'], " ")).unwrap_or_default(); + let shown = widgets::elide_font(ui, &name, ui.available_width() - PAD - widgets::PAD_RIGHT, semibold(11.0)); + let (_, header) = widgets::section_header_resp(ui, &shown, &[], false); + if shown != name { + header.on_hover_text(name); + } crate::dev_mode::status(app, ui, &doc, &sel); crate::dev_mode::annotations_section(app, ui, &doc, first); crate::dev_mode::dev_resources_section(app, ui, &doc, first); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c0f2f814..c3b64c0e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -775,31 +775,36 @@ pub fn elide(ui: &Ui, s: &str, max_w: f32) -> String { /// [`elide`] in `font`. pub fn elide_font(ui: &Ui, s: &str, max_w: f32, font: egui::FontId) -> String { - let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), Color32::WHITE).size().x <= max_w; - if fits(s) { - return s.to_string(); - } - let chars: Vec = s.chars().collect(); - (1..chars.len()) - .rev() - .map(|n| format!("{}…", chars.iter().take(n).collect::().trim_end())) - .find(|c| fits(c)) - .unwrap_or_else(|| "…".into()) + elide_with(ui, s, max_w, font, |chars, n| format!("{}…", chars.iter().take(n).collect::().trim_end())) } /// [`elide_font`] cut at its start, keeping its end: a path keeps its file and the folders /// nearest it. pub fn elide_start_font(ui: &Ui, s: &str, max_w: f32, font: egui::FontId) -> String { + elide_with(ui, s, max_w, font, |chars, n| format!("…{}", chars[chars.len() - n..].iter().collect::().trim_start())) +} + +/// `s` if it fits `max_w` in `font`, else the longest `shortened(chars, n)` (keeping `n` of its +/// characters, 0 < n < all) that fits, else "…". Keeping more characters never makes it narrower, +/// so the longest is found by halving: a name of thousands of characters (#1650) lays out about a +/// dozen candidates, not one per character, on every frame. +fn elide_with(ui: &Ui, s: &str, max_w: f32, font: egui::FontId, shortened: impl Fn(&[char], usize) -> String) -> String { let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), Color32::WHITE).size().x <= max_w; if fits(s) { return s.to_string(); } let chars: Vec = s.chars().collect(); - (1..chars.len()) - .rev() - .map(|n| format!("…{}", chars[chars.len() - n..].iter().collect::().trim_start())) - .find(|c| fits(c)) - .unwrap_or_else(|| "…".into()) + // Keeping `fit` characters fits (0: nothing kept yet); keeping `over` doesn't (all of them). + let (mut fit, mut over) = (0, chars.len()); + while over - fit > 1 { + let mid = fit + (over - fit) / 2; + if fits(&shortened(&chars, mid)) { + fit = mid; + } else { + over = mid; + } + } + if fit == 0 { "…".into() } else { shortened(&chars, fit) } } /// A row of icon buttons; returns the index clicked. diff --git a/docs/roadmap.md b/docs/roadmap.md index b2ee3980..07bfef39 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-30 · **Change:** minor (with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); 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 @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, #1650: a very long frame name made the Export section's **Export <name>** button wrap its label into a tall block centred on the button, painted over Position, Layout, Appearance, Fill and the rest of the Design panel. The button now shows one line ending with an ellipsis, with the full name on hover, and line breaks in a name show as spaces. Dev Mode's header, which names the layer, does the same instead of running off the panel's edge. Shortening text to fit now halves its way to the longest fit instead of trying one candidate per character: with a 4,000-character name that kept frames at about 0.4 ms instead of about 9.5 ms, and it finds exactly what the old search found. | | 2026-10-30 | minor | M8, #1571 (part 5, fixes it): with 648 layers selected, the right panel cost 0.55 ms on every frame against 0.19 ms with one layer, and the canvas overlays 0.27 ms against 0.16. Each Design panel section went over the whole selection several times a frame for mixed values and which kinds are selected (about 20 passes, each looking up 648 layers in the document). The overlays did the same for boolean operand outlines and constraint guides. Now the panel looks the selection up once per document snapshot and selection; while a drag only moves the layers, that is the document the drag started from. `common` and a new `every` read those layers. The overlays keep which layers have constraint guides and which boolean operands to outline, worked out once per document and selection; during such a drag, from the start document, since a move changes neither. Measured on the same synthetic file of about 1,900 layers: the drag-all UI mean went from 1.48 to 1.04–1.09 ms per frame, and hovering with all selected from 0.95–1.04 to 0.64–0.69 ms. The right panel went to 0.25 ms and the overlays to 0.18 ms. Screenshots of the panel and the overlays are byte-identical before and after. Tests cover the kept layers after edits, another selection, a deleted layer and another document, and the overlays after structure edits. | | 2026-10-30 | minor | M6, gap 6: `image` controls in plugin panels (base64, 256 KB, 4,096 px a side; specs up to 1 MB), named by their label. Tests: `v2_tests`, `plugin_panel_tests` | | 2026-10-30 | minor | M6, gap 1: `.fig` connector labels without a saved snapshot are kept, centred on the line, instead of skipped. Test: `fig::tests::tail` | From 4f5a25cc8274a25656b31a530abb3ee510ae1df6 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 22:32:44 +0700 Subject: [PATCH 08/10] M12: spreadsheet cells paste onto a board as a table (#1663) --- ROADMAP.md | 3 +- crates/engine/src/board_table_paste_tests.rs | 81 ++++++++++++++++++++ crates/engine/src/boards/board_table.rs | 51 ++++++++++++ crates/engine/src/lib.rs | 2 + crates/ui-egui/locales/ar.toml | 1 + crates/ui-egui/locales/bn.toml | 1 + crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fi.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/it.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ka.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/nl.toml | 1 + crates/ui-egui/locales/pl.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/th.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/vi.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus/clipboard.rs | 17 +++- crates/ui-egui/src/table_paste_tests.rs | 73 ++++++++++++++++++ docs/boards-parity.md | 5 +- docs/roadmap.md | 3 +- 31 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/board_table_paste_tests.rs create mode 100644 crates/ui-egui/src/table_paste_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 958c5d1b..8117e954 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-30 · **Change:** minor (a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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 @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M12, gap 24: cells copied from a spreadsheet paste onto a board as a table. The clipboard carries them as text: a row per line, a tab between cells, and a cell holding a tab, a line break or a quote in quotes. `board_table::spreadsheet_cells` reads that into `board.table`'s `cells`. The table is centred in the view, or with Paste here its top-left goes at the point. Up to 50 × 20 cells are kept, with a note when there were more. Text without tabs, comma-separated text and any paste into a design file stay a text layer, as before. Tests: `board_table_paste_tests`, `table_paste_tests`. | | 2026-10-30 | minor | M6, #1650: a very long frame name made the Export section's **Export <name>** button wrap its label into a tall block centred on the button, painted over Position, Layout, Appearance, Fill and the rest of the Design panel. The button now shows one line ending with an ellipsis, with the full name on hover, and line breaks in a name show as spaces. Dev Mode's header, which names the layer, does the same instead of running off the panel's edge. Shortening text to fit now halves its way to the longest fit instead of trying one candidate per character: with a 4,000-character name that kept frames at about 0.4 ms instead of about 9.5 ms, and it finds exactly what the old search found. | | 2026-10-30 | minor | M8, #1571 (part 5, fixes it): with 648 layers selected, the right panel cost 0.55 ms on every frame against 0.19 ms with one layer, and the canvas overlays 0.27 ms against 0.16. Each Design panel section went over the whole selection several times a frame for mixed values and which kinds are selected (about 20 passes, each looking up 648 layers in the document). The overlays did the same for boolean operand outlines and constraint guides. Now the panel looks the selection up once per document snapshot and selection; while a drag only moves the layers, that is the document the drag started from. `common` and a new `every` read those layers. The overlays keep which layers have constraint guides and which boolean operands to outline, worked out once per document and selection; during such a drag, from the start document, since a move changes neither. Measured on the same synthetic file of about 1,900 layers: the drag-all UI mean went from 1.48 to 1.04–1.09 ms per frame, and hovering with all selected from 0.95–1.04 to 0.64–0.69 ms. The right panel went to 0.25 ms and the overlays to 0.18 ms. Screenshots of the panel and the overlays are byte-identical before and after. Tests cover the kept layers after edits, another selection, a deleted layer and another document, and the overlays after structure edits. | | 2026-10-30 | minor | M6, gap 6: plugin panels show pictures. An `image` control carries a PNG, JPEG, WebP, GIF, BMP or TIFF file as base64 (`data`, at most 256 KB; a panel spec may now be 1 MB) and is drawn at its size, at most a field wide and 240 pt tall, never scaled up; screen readers read its `label`. The app decodes it with the thumbnail decoder (its allocation limit), refuses pictures over 4,096 px on a side before decoding them, keeps the last 32 decoded, and shows the label instead of a picture it can't show. Images can't be clicked (`plugin.event` refuses them) and can't be command parameters. Also: a panel's or form's captions no longer wrap ("Palette" broke onto two lines when it was the widest). Numbers unchanged. Tests: `v2_tests`, `tests` (manifest), `plugin_panel_tests::panel_images_show_named_or_fall_back_to_their_label`. | diff --git a/crates/engine/src/board_table_paste_tests.rs b/crates/engine/src/board_table_paste_tests.rs new file mode 100644 index 00000000..1a4e2b21 --- /dev/null +++ b/crates/engine/src/board_table_paste_tests.rs @@ -0,0 +1,81 @@ +//! Spreadsheet cells as a board table's cells (`board_table::spreadsheet_cells`): rows on lines, +//! cells between tabs, quoted cells holding tabs, line breaks and quotes; other text isn't a table. + +use serde_json::json; + +use crate::Session; +use crate::board_table::{MAX_COLUMNS, MAX_ROWS, spreadsheet_cells}; + +fn cells(text: &str) -> Vec> { + let (cells, more) = spreadsheet_cells(text).expect("a table"); + assert!(!more, "nothing left out"); + cells +} + +fn rows(r: &[&[&str]]) -> Vec> { + r.iter().map(|row| row.iter().map(|c| (*c).to_string()).collect()).collect() +} + +#[test] +fn spreadsheet_text_makes_rows_of_cells() { + assert_eq!(cells("Name\tRole\nAda\tLead\n"), rows(&[&["Name", "Role"], &["Ada", "Lead"]]), "a copy ends with a line break"); + assert_eq!(cells("Name\tRole\r\nAda\tLead\r\n"), rows(&[&["Name", "Role"], &["Ada", "Lead"]]), "Windows line breaks"); + assert_eq!(cells("a\t\tc\n\tb\t"), rows(&[&["a", "", "c"], &["", "b", ""]]), "empty cells keep their place"); + assert_eq!(cells("a\tb\nc"), rows(&[&["a", "b"], &["c"]]), "a short row stays short (the table fills it)"); + assert_eq!(cells("Title\nx\ty"), rows(&[&["Title"], &["x", "y"]]), "one row of two cells is enough"); +} + +#[test] +fn quoted_cells_hold_tabs_line_breaks_and_quotes() { + let text = "\"a\tb\"\t\"line 1\nline 2\"\t\"say \"\"hi\"\"\"\n\"\"\tplain \"quote\""; + assert_eq!(cells(text), rows(&[&["a\tb", "line 1\nline 2", "say \"hi\""], &["", "plain \"quote\""]])); +} + +#[test] +fn other_text_is_not_a_table() { + for text in ["", "just text", "one\ntwo\nthree", "a, b, c\n1, 2, 3", "\n\n"] { + assert_eq!(spreadsheet_cells(text), None, "{text:?}"); + } +} + +#[test] +fn a_table_keeps_at_most_its_largest_size() { + let row = vec!["x"; MAX_COLUMNS + 5].join("\t"); + let text = vec![row.as_str(); MAX_ROWS + 10].join("\n"); + let (cells, more) = spreadsheet_cells(&text).expect("a table"); + assert!(more, "the rest is left out, and said so"); + assert_eq!(cells.len(), MAX_ROWS); + assert!(cells.iter().all(|r| r.len() == MAX_COLUMNS)); +} + +#[test] +fn pasted_cells_make_a_board_table() { + let mut s = Session::new(); + s.execute("file.newBoard", &json!({})).unwrap(); + let (cells, _) = spreadsheet_cells("Name\tRole\tTeam\nAda\tLead\n").unwrap(); + let r = s.execute("board.table", &json!({"cells": cells, "x": 0, "y": 0})).unwrap(); + assert_eq!((r["rows"].as_u64(), r["columns"].as_u64()), (Some(2), Some(3))); + let doc = s.doc().unwrap(); + let text = |row: usize, col: usize| { + let id = crate::params::id_of(&r["cells"][row][col]).unwrap(); + doc.node(id).and_then(|n| n.kind.text()).map(|t| t.characters.clone()).unwrap() + }; + assert_eq!((text(0, 0), text(0, 2), text(1, 1), text(1, 2)), ("Name".into(), "Team".into(), "Lead".into(), String::new())); +} + +#[test] +fn junk_never_panics() { + let parts = ["\t", "\n", "\r", "\r\n", "\"", "\"\"", "a", "é", "漢", " "]; + let mut seed = 7u64; + for _ in 0..2000 { + let mut text = String::new(); + for _ in 0..(seed % 24) { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + text.push_str(parts[(seed >> 33) as usize % parts.len()]); + } + if let Some((cells, _)) = spreadsheet_cells(&text) { + assert!(cells.len() <= MAX_ROWS && cells.iter().all(|r| r.len() <= MAX_COLUMNS)); + assert!(cells.iter().any(|r| r.len() > 1), "{text:?}"); + } + } +} diff --git a/crates/engine/src/boards/board_table.rs b/crates/engine/src/boards/board_table.rs index 9cf35dcb..aef7c891 100644 --- a/crates/engine/src/boards/board_table.rs +++ b/crates/engine/src/boards/board_table.rs @@ -48,6 +48,57 @@ pub fn specs() -> Vec { ] } +/// Cells copied from a spreadsheet, as `board.table`'s `cells`: the clipboard holds them as text, +/// a row per line and a tab between cells, a cell holding a tab, a line break or a quote quoted +/// (`""` for a quote). At most [`MAX_ROWS`] × [`MAX_COLUMNS`] are kept; the flag says whether +/// there were more. `None` for text without a tab, or with one cell per row (other text: it +/// pastes as text). +pub fn spreadsheet_cells(text: &str) -> Option<(Vec>, bool)> { + if !text.contains('\t') { + return None; + } + // A spreadsheet ends its copy with a line break: no empty row after the last. + let text = text.strip_suffix("\r\n").or_else(|| text.strip_suffix('\n')).unwrap_or(text); + let (mut rows, mut row, mut cell) = (Vec::new(), Vec::new(), String::new()); + let (mut quoted, mut cell_start) = (false, true); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if quoted { + match c { + '"' if chars.peek() == Some(&'"') => { + chars.next(); + cell.push('"'); + } + '"' => quoted = false, + c => cell.push(c), + } + continue; + } + match c { + '"' if cell_start => quoted = true, + '\t' => row.push(std::mem::take(&mut cell)), + '\r' if chars.peek() == Some(&'\n') => continue, + '\n' | '\r' => { + row.push(std::mem::take(&mut cell)); + rows.push(std::mem::take(&mut row)); + } + c => cell.push(c), + } + cell_start = matches!(c, '\t' | '\n' | '\r'); + } + row.push(cell); + rows.push(row); + if !rows.iter().any(|r| r.len() > 1) { + return None; + } + let more = rows.len() > MAX_ROWS || rows.iter().any(|r| r.len() > MAX_COLUMNS); + rows.truncate(MAX_ROWS); + for r in &mut rows { + r.truncate(MAX_COLUMNS); + } + Some((rows, more)) +} + /// A count from 1 to `max` (`default` when missing). fn count(cmd: &str, p: &Value, key: &str, default: usize, max: usize) -> Result { match num(p, key) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b4046193..a66c414d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -207,6 +207,8 @@ mod board_connector_tests; #[cfg(test)] mod board_grid_tests; #[cfg(test)] +mod board_table_paste_tests; +#[cfg(test)] mod board_tables_tests; #[cfg(test)] mod board_tests; diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index a727770e..449a077b 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -26,6 +26,7 @@ status = "draft — needs native review" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "تُحاذى الطبقة المنفردة مع إطارها: حدد عدة طبقات أو طبقة داخل إطار" "A responsive product page: hero, features, figures, a quote and a footer" = "صفحة منتج متجاوبة: قسم رئيسي ومزايا وأرقام واقتباس وتذييل" "A spring plays until it settles: its mass, stiffness and damping set how long" = "تستمر حركة النابض حتى يستقر؛ وتحدد كتلته وصلابته وتخميده مدتها" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "يتسع الجدول لـ {rows} صفًا و{columns} عمودًا على الأكثر: لم يُلصق الباقي" "A title, an agenda, a big figure, a chart and a closing slide" = "عنوان وجدول أعمال ورقم بارز ومخطط وشريحة ختامية" "A to Z" = "من A إلى Z" "About" = "حول" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index c19daa13..4da435d3 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -21,6 +21,7 @@ status = "draft — needs native review" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "একটি লেয়ার একা তার ফ্রেমের সাথে সারিবদ্ধ হয়: আরও লেয়ার নির্বাচন করুন, বা ফ্রেমে থাকা একটি লেয়ার নির্বাচন করুন" "A responsive product page: hero, features, figures, a quote and a footer" = "একটি রেসপনসিভ প্রোডাক্ট পেজ: হিরো, ফিচার, ফিগার, একটি উদ্ধৃতি এবং একটি ফুটার" "A spring plays until it settles: its mass, stiffness and damping set how long" = "একটি স্প্রিং স্থির হওয়া পর্যন্ত চলে: এর ভর, দৃঢ়তা (stiffness) এবং ড্যাম্পিং নির্ধারণ করে কতক্ষণ চলবে" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "একটি টেবিলে সর্বোচ্চ {rows}টি সারি ও {columns}টি কলাম থাকে: বাকিটা পেস্ট হয়নি" "A title, an agenda, a big figure, a chart and a closing slide" = "একটি শিরোনাম, একটি আলোচ্যসূচি, একটি বড় ফিগার, একটি চার্ট এবং একটি সমাপ্তি স্লাইড" "A to Z" = "A থেকে Z" "About" = "সম্পর্কিত" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index efd55667..ea7960af 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Samostatná vrstva se zarovná ke svému rámci: vyberte více vrstev nebo jednu v rámci" "A responsive product page: hero, features, figures, a quote and a footer" = "Responzivní produktová stránka: úvodní blok, funkce, čísla, citace a zápatí" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Pružina se pohybuje až do ustálení: délku určuje její hmotnost, tuhost a tlumení" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Tabulka má nejvýše {rows} řádků a {columns} sloupců: zbytek se nevložil" "A title, an agenda, a big figure, a chart and a closing slide" = "Titulní snímek, program, výrazné číslo, graf a závěrečný snímek" "A to Z" = "Od A do Z" "About" = "O aplikaci" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 83a5723f..1578e75d 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -20,6 +20,7 @@ status = "draft — needs native review" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Eine .figcraft-Datei mit diesem Namen existiert bereits. Wählen Sie ihren vollständigen Namen, um das Ersetzen zu bestätigen." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Eine einzelne Ebene richtet sich an ihrem Rahmen aus: Wählen Sie weitere Ebenen oder eine Ebene in einem Rahmen" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Eine Feder läuft, bis sie zur Ruhe kommt: Masse, Steifigkeit und Dämpfung bestimmen die Dauer" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Eine Tabelle hat höchstens {rows} Zeilen und {columns} Spalten: Der Rest wurde nicht eingefügt" "About" = "Über" "About FigCraft" = "Über FigCraft" "About this server" = "Über diesen Server" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 0a3274e9..17a1b99b 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -20,6 +20,7 @@ name = "English" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "A layer on its own aligns to its frame: select more layers, or one in a frame" "A responsive product page: hero, features, figures, a quote and a footer" = "A responsive product page: hero, features, figures, a quote and a footer" "A spring plays until it settles: its mass, stiffness and damping set how long" = "A spring plays until it settles: its mass, stiffness and damping set how long" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" "A title, an agenda, a big figure, a chart and a closing slide" = "A title, an agenda, a big figure, a chart and a closing slide" "A to Z" = "A to Z" "About" = "About" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index c2eb2220..2f3602c2 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,6 +8,7 @@ name = "Español" status = "draft — needs native review" [ui] +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Una tabla admite como máximo {rows} filas y {columns} columnas: el resto no se pegó" "Add a connected shape" = "Añadir una forma conectada" "Add a minute" = "Añadir un minuto" "Add column" = "Añadir columna" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index c7832bbd..12020ea8 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -22,6 +22,7 @@ status = "draft — needs native review" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Yksittäinen taso tasataan kehykseensä. Valitse useampia tasoja tai yksi kehyksen sisällä oleva taso." "A responsive product page: hero, features, figures, a quote and a footer" = "Mukautuva tuotesivu: pääosio, ominaisuudet, tunnusluvut, lainaus ja alatunniste" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Jousi liikkuu, kunnes se asettuu. Sen massa, jäykkyys ja vaimennus määräävät keston." +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Taulukossa voi olla enintään {rows} riviä ja {columns} saraketta: loppua ei liitetty" "A title, an agenda, a big figure, a chart and a closing slide" = "Otsikko, esityslista, suuri tunnusluku, kaavio ja päätösdia" "A to Z" = "A:sta Z:aan" "About" = "Tietoja" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index c9ce81a1..5291647e 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -21,6 +21,7 @@ status = "draft — needs native review" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un calque seul s’aligne sur son cadre : sélectionnez d’autres calques, ou un calque dans un cadre" "A responsive product page: hero, features, figures, a quote and a footer" = "Une page produit responsive : héros, fonctionnalités, chiffres, une citation et un pied de page" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Un ressort joue jusqu’à ce qu’il se stabilise : sa masse, sa rigidité et son amortissement en fixent la durée" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Un tableau compte au plus {rows} lignes et {columns} colonnes : le reste n'a pas été collé" "A title, an agenda, a big figure, a chart and a closing slide" = "Un titre, un sommaire, un grand chiffre, un graphique et une diapositive de clôture" "A to Z" = "A à Z" "About" = "À propos" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index f7a9e986..dc453d3b 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -8,6 +8,7 @@ name = "Bahasa Indonesia" status = "draft — needs native review" [ui] +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Tabel memuat paling banyak {rows} baris dan {columns} kolom: sisanya tidak ditempel" "Add a connected shape" = "Tambahkan bentuk yang terhubung" "Pasted design layers" = "Lapisan desain yang ditempel" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Lapisan desain ditempel; papan klip tidak membawa gambar dari: {layers}" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index 424c88e4..fe471dbe 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -20,6 +20,7 @@ status = "draft — needs native review" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un livello da solo si allinea al proprio frame: seleziona più livelli, oppure uno dentro un frame" "A responsive product page: hero, features, figures, a quote and a footer" = "Una pagina prodotto responsive: hero, funzionalità, figure, una citazione e un footer" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Una molla si muove finché non si assesta: massa, rigidità e smorzamento ne determinano la durata" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Una tabella ha al massimo {rows} righe e {columns} colonne: il resto non è stato incollato" "A title, an agenda, a big figure, a chart and a closing slide" = "Un titolo, un'agenda, una cifra grande, un grafico e una slide di chiusura" "A to Z" = "Dalla A alla Z" "About" = "Informazioni" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 79034ebd..80220c1a 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -23,6 +23,7 @@ status = "draft — needs native review" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "単独のレイヤーはフレームに整列します:レイヤーを追加で選択するか、フレーム内のレイヤーを選択" "A responsive product page: hero, features, figures, a quote and a footer" = "レスポンシブな製品ページ:ヒーロー、機能、数値、引用、フッター" "A spring plays until it settles: its mass, stiffness and damping set how long" = "スプリングは静止するまで再生。長さは質量・剛性・減衰で決まる" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "表は最大 {rows} 行 {columns} 列です。残りは貼り付けられませんでした" "A title, an agenda, a big figure, a chart and a closing slide" = "タイトル、目次、大きな数値、グラフ、締めのスライド" "A to Z" = "昇順(A→Z)" "About" = "情報" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 4c7e9627..4e85201f 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -23,6 +23,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "A layer on its own aligns to its frame: select more layers, or one in a frame" = "ცალკე შრე თავის ჩარჩოსთან სწორდება: მონიშნეთ მეტი შრე ან ჩარჩოში მყოფი შრე" "A responsive product page: hero, features, figures, a quote and a footer" = "პროდუქტის ადაპტური გვერდი ყველა ძირითადი ბლოკით" "A spring plays until it settles: its mass, stiffness and damping set how long" = "ზამბარა მოძრაობს, სანამ არ დამშვიდდება: მისი მასა, სიხისტე და მილევა განსაზღვრავს ხანგრძლივობას" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "ცხრილში მაქსიმუმ {rows} მწკრივი და {columns} სვეტია: დანარჩენი არ ჩაისვა" "A title, an agenda, a big figure, a chart and a closing slide" = "სათაური, დღის წესრიგი, დიდი ციფრი, დიაგრამა და დასკვნითი სლაიდი" "A to Z" = "ა-დან ჰ-მდე" "About" = "პროგრამის შესახებ" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 0ece525d..fbc6353d 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -21,6 +21,7 @@ status = "draft — needs native review" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "이 이름의 .figcraft 파일이 이미 있습니다. 바꾸려면 확장자까지 포함한 전체 이름을 선택하세요." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "레이어 하나는 프레임에 맞춰 정렬됩니다: 레이어를 더 선택하거나 프레임 안의 레이어를 선택하세요" "A spring plays until it settles: its mass, stiffness and damping set how long" = "스프링은 멈출 때까지 재생됨. 길이는 질량, 강성, 감쇠로 결정됨" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "표는 최대 {rows}행 {columns}열입니다. 나머지는 붙여넣지 않았습니다" "About" = "정보" "About FigCraft" = "FigCraft 정보" "About this server" = "이 서버 정보" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 1b3adb09..5fc56d79 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -22,6 +22,7 @@ status = "draft — needs native review" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Een losse laag wordt uitgelijnd op zijn frame: selecteer meer lagen, of één in een frame" "A responsive product page: hero, features, figures, a quote and a footer" = "Een responsive productpagina: hero, features, afbeeldingen, een citaat en een footer" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Een veer speelt tot hij tot rust komt: zijn massa, stijfheid en demping bepalen hoe lang" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Een tabel heeft hoogstens {rows} rijen en {columns} kolommen: de rest is niet geplakt" "A title, an agenda, a big figure, a chart and a closing slide" = "Een titel, een agenda, een grote afbeelding, een grafiek en een afsluitende dia" "A to Z" = "A tot Z" "About" = "Over" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 75517084..52a6c5d7 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -20,6 +20,7 @@ name = "Polski" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Pojedyncza warstwa wyrównuje się do swojej ramki: zaznacz więcej warstw lub warstwę w ramce" "A responsive product page: hero, features, figures, a quote and a footer" = "Responsywna strona produktu: sekcja hero, funkcje, statystyki, cytat i stopka" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Animacja sprężynowa trwa do ustabilizowania: jej masa, sztywność i tłumienie określają czas" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Tabela ma najwyżej {rows} wierszy i {columns} kolumn: reszta nie została wklejona" "A title, an agenda, a big figure, a chart and a closing slide" = "Tytuł, agenda, wyróżniona liczba, wykres i slajd końcowy" "A to Z" = "Od A do Z" "About" = "O programie" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 1c20f6ec..ae1ee18f 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -19,6 +19,7 @@ status = "draft — needs native review" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Já existe um arquivo .figcraft com este nome. Escolha o nome completo dele para confirmar a substituição." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Uma camada sozinha se alinha ao seu quadro: selecione mais camadas ou uma dentro de um quadro" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Uma mola se move até se estabilizar: sua massa, rigidez e amortecimento definem a duração" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Uma tabela tem no máximo {rows} linhas e {columns} colunas: o resto não foi colado" "About" = "Sobre" "About FigCraft" = "Sobre o FigCraft" "About this server" = "Sobre este servidor" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index ed67090b..19283a45 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -20,6 +20,7 @@ name = "Русский" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Отдельный слой выравнивается по своему фрейму: выделите больше слоёв или слой внутри фрейма." "A responsive product page: hero, features, figures, a quote and a footer" = "Адаптивная страница продукта: главный блок, возможности, показатели, цитата и подвал" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Пружинная анимация длится до затухания: её время задают масса, жёсткость и демпфирование" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "В таблице не больше {rows} строк и {columns} столбцов: остальное не вставлено" "A title, an agenda, a big figure, a chart and a closing slide" = "Заголовок, повестка, крупный показатель, диаграмма и заключительный слайд" "A to Z" = "От А до Я" "About" = "О программе" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index ba46955b..f3b7bb9a 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -20,6 +20,7 @@ name = "Svenska" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Ett ensamt lager justeras mot sin frame: markera fler lager, eller ett lager i en frame" "A responsive product page: hero, features, figures, a quote and a footer" = "En responsiv produktsida: toppsektion, funktioner, figurer, ett citat och en sidfot" "A spring plays until it settles: its mass, stiffness and damping set how long" = "En fjäder spelas tills den stannar: dess massa, styvhet och dämpning avgör hur länge" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "En tabell har högst {rows} rader och {columns} kolumner: resten klistrades inte in" "A title, an agenda, a big figure, a chart and a closing slide" = "En titel, en agenda, en stor figur, ett diagram och en avslutande bild" "A to Z" = "A till Ö" "About" = "Om" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index 942483b0..676ff55e 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -25,6 +25,7 @@ status = "draft — needs native review" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "เลเยอร์เดี่ยวจะจัดแนวตามเฟรมของมัน: เลือกเลเยอร์เพิ่ม หรือเลือกเลเยอร์ในเฟรม" "A responsive product page: hero, features, figures, a quote and a footer" = "หน้าสินค้าแบบรีสปอนซีฟ: ฮีโร่ ฟีเจอร์ ตัวเลข คำพูดอ้างอิง และฟุตเตอร์" "A spring plays until it settles: its mass, stiffness and damping set how long" = "สปริงเล่นจนนิ่ง: มวล ความแข็ง และการหน่วงกำหนดระยะเวลา" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "ตารางมีได้สูงสุด {rows} แถวและ {columns} คอลัมน์: ส่วนที่เหลือไม่ได้วาง" "A title, an agenda, a big figure, a chart and a closing slide" = "ชื่อเรื่อง วาระ ตัวเลขใหญ่ แผนภูมิ และสไลด์ปิดท้าย" "A to Z" = "A ถึง Z" "About" = "เกี่ยวกับ" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index cd0760e4..1c78b81c 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -21,6 +21,7 @@ name = "Türkçe" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Bu adda bir .figcraft dosyası zaten var. Değiştirmeyi onaylamak için tam adını seçin." "A responsive product page: hero, features, figures, a quote and a footer" = "Duyarlı bir ürün sayfası: giriş bölümü, özellikler, rakamlar, bir alıntı ve bir alt bilgi" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Yay, durulana kadar oynar: kütlesi, sertliği ve sönümü ne kadar süreceğini belirler" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Bir tablo en fazla {rows} satır ve {columns} sütun alır: gerisi yapıştırılmadı" "A title, an agenda, a big figure, a chart and a closing slide" = "Bir başlık, bir gündem, büyük bir rakam, bir grafik ve bir kapanış slaydı" "A to Z" = "A'dan Z'ye" "About" = "Hakkında" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index 294efc7f..1f2055c4 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -21,6 +21,7 @@ status = "draft — needs native review" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Một lớp riêng lẻ sẽ căn chỉnh theo khung của nó: hãy chọn thêm lớp, hoặc chọn một lớp bên trong khung" "A responsive product page: hero, features, figures, a quote and a footer" = "Trang sản phẩm đáp ứng: phần mở đầu, tính năng, số liệu, trích dẫn và chân trang" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Chuyển động lò xo chạy cho đến khi ổn định: khối lượng, độ cứng và độ giảm chấn quyết định thời lượng" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Một bảng có tối đa {rows} hàng và {columns} cột: phần còn lại không được dán" "A title, an agenda, a big figure, a chart and a closing slide" = "Tiêu đề, chương trình, số liệu lớn, biểu đồ và trang kết thúc" "A to Z" = "A đến Z" "About" = "Giới thiệu" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index ce472ab7..6f628d92 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -22,6 +22,7 @@ status = "draft — needs native review" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "已存在同名的 .figcraft 文件。请选择其完整名称以确认替换。" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "单个图层与其所在画框对齐:请再选择其他图层,或选择画框内的图层" "A spring plays until it settles: its mass, stiffness and damping set how long" = "弹簧动画播放至静止,时长由质量、刚度和阻尼决定" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "表格最多 {rows} 行 {columns} 列:其余内容未粘贴" "About" = "关于" "About FigCraft" = "关于 FigCraft" "About this server" = "关于此服务器" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 658b1d45..135e7b77 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -233,6 +233,8 @@ mod style_folder_rename_tests; #[cfg(test)] mod style_props_tests; #[cfg(test)] +mod table_paste_tests; +#[cfg(test)] mod text_enter_tests; #[cfg(test)] mod titlebar_tests; diff --git a/crates/ui-egui/src/menus/clipboard.rs b/crates/ui-egui/src/menus/clipboard.rs index 26c2e4df..ff065a48 100644 --- a/crates/ui-egui/src/menus/clipboard.rs +++ b/crates/ui-egui/src/menus/clipboard.rs @@ -92,12 +92,27 @@ fn is_clipboard_marker(text: &str) -> bool { } /// Paste text from another app at `(cx, cy)` (`center`: centred there): a comment's link opens its -/// thread, SVG imports, anything else becomes a text layer. +/// thread, SVG imports, spreadsheet cells on a board make a table, anything else becomes a text +/// layer. fn paste_text(app: &mut FigApp, text: &str, (cx, cy): (f64, f64), center: bool) { + let board = app.session.doc().is_ok_and(figcraft_engine::board::is_board); if text.trim().starts_with("figcraft://comment/") { app.run("comment.open", json!({"link": text.trim()})); } else if dev_mode_refuses(app, "edit.paste") { // Dev Mode: the note says why nothing was pasted. + } else if let Some((cells, more)) = board.then(|| figcraft_engine::board_table::spreadsheet_cells(text)).flatten() { + // Centred in the view, or (Paste here) its top-left at the point. + let mut params = json!({"cells": cells}); + if !center { + (params["x"], params["y"]) = (json!(cx), json!(cy)); + } + if app.run("board.table", params).is_some() && more { + let (rows, columns) = (figcraft_engine::board_table::MAX_ROWS, figcraft_engine::board_table::MAX_COLUMNS); + app.toast(trf( + "A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted", + &[("rows", &rows.to_string()), ("columns", &columns.to_string())], + )); + } } else if figcraft_engine::looks_like_svg(text) { use base64::Engine as _; let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); diff --git a/crates/ui-egui/src/table_paste_tests.rs b/crates/ui-egui/src/table_paste_tests.rs new file mode 100644 index 00000000..aaecd63b --- /dev/null +++ b/crates/ui-egui/src/table_paste_tests.rs @@ -0,0 +1,73 @@ +//! Pasting spreadsheet cells: on a board they make a table (centred in the view, or with Paste here +//! its top-left at the point); in a design file, or text that isn't a grid of cells, a text layer as +//! before. + +use figcraft_doc::{BoardItem, Document, NodeKind}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, NoServices}; + +const CELLS: &str = "Name\tRole\nAda\tLead\nGrace\tReview\n"; + +fn app(board: bool) -> FigApp { + let mut s = Session::new(); + if board { + s.execute("file.newBoard", &json!({})).unwrap(); + } + FigApp::new(s, Box::new(NoServices)) +} + +fn tables(doc: &Document) -> Vec { + doc.ids().filter(|i| doc.node(*i).is_some_and(|n| n.board == Some(BoardItem::Table))).collect() +} + +fn texts(doc: &Document) -> usize { + doc.ids().filter(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))).count() +} + +#[test] +fn spreadsheet_cells_paste_as_a_table_on_a_board() { + let mut app = app(true); + crate::menus::paste(&mut app, Some(CELLS), None); + let doc = app.session.doc().unwrap(); + let [table] = tables(doc)[..] else { panic!("one table") }; + assert_eq!(app.session.selection(), vec![table], "selected, as a new table is"); + let rows = doc.node(table).unwrap().children.iter().filter(|r| doc.node(**r).is_some_and(|n| !n.child_layout.absolute)).count(); + assert_eq!(rows, 3); + let view = app.session.ds().unwrap().view.clone(); + let b = doc.world_bounds(table).unwrap(); + assert!((b.center().x - view.cx).abs() < 1.0, "centred in the view: {b:?}"); + // Paste here: its top-left at the point. + crate::menus::paste(&mut app, Some(CELLS), Some((500.0, 700.0))); + let doc = app.session.doc().unwrap(); + let placed = tables(doc).into_iter().find(|t| *t != table).expect("a second table"); + let b = doc.world_bounds(placed).unwrap(); + assert_eq!((b.x0, b.y0), (500.0, 700.0)); + assert!(app.ui.toast.is_none(), "nothing left out, nothing to say"); +} + +#[test] +fn a_larger_paste_keeps_the_largest_table_and_says_so() { + let mut app = app(true); + let row = vec!["x"; 25].join("\t"); + crate::menus::paste(&mut app, Some(&vec![row.as_str(); 60].join("\n")), None); + assert_eq!(tables(app.session.doc().unwrap()).len(), 1); + assert!(app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("50 rows and 20 columns")), "the rest wasn't pasted"); +} + +#[test] +fn other_pastes_stay_text() { + // Cells pasted into a design file. + let mut design = app(false); + let before = texts(design.session.doc().unwrap()); + crate::menus::paste(&mut design, Some(CELLS), None); + assert!(tables(design.session.doc().unwrap()).is_empty()); + assert_eq!(texts(design.session.doc().unwrap()), before + 1, "a text layer"); + // Lines without tabs on a board. + let mut board = app(true); + let before = texts(board.session.doc().unwrap()); + crate::menus::paste(&mut board, Some("first idea\nsecond idea"), None); + assert!(tables(board.session.doc().unwrap()).is_empty()); + assert_eq!(texts(board.session.doc().unwrap()), before + 1); +} diff --git a/docs/boards-parity.md b/docs/boards-parity.md index b9024e94..8f2cd3fc 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,6 +1,6 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-30 · **Change:** minor (Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-30 · **Change:** minor (cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) **Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind (`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that @@ -52,7 +52,7 @@ all end with the same geometry. | Voting: only the facilitator ends or clears it, who voted for what | missing | anyone who can edit the board can end or clear a vote; results are counts only | | Board widgets | missing | design-file widgets exist (`crates/engine/src/extensions/widgets.rs`), not wired to boards | | Templates (retrospective, brainstorm, flow chart…) | partial | five built-in templates drawn up for FigCraft (retrospective, brainstorm, weekly planner, flow chart, kanban board), placed from the toolbar's templates picker (each with a picture of itself) or `board.template {name, x?, y?}` as ordinary sections, stickies, shapes, connectors and text in one undo step; `board.templates` lists them. No community templates, no saving your own | -| Tables | partial | `board.table {rows, columns, cells?, x?, y?}` (at most 50 × 20): an auto layout frame of rows of cells with text, a tinted header row and lines between rows and columns; a cell's row grows with its text; a double-click types in the cell under the pointer; add or remove a row or column from the bar over the selection or `board.tableEdit {id, action, index?}`; undoable and synced in live sessions. No column resizing by dragging, no cell colours, no Tab between cells, no CSV paste | +| Tables | partial | `board.table {rows, columns, cells?, x?, y?}` (at most 50 × 20): an auto layout frame of rows of cells with text, a tinted header row and lines between rows and columns; a cell's row grows with its text; a double-click types in the cell under the pointer; add or remove a row or column from the bar over the selection or `board.tableEdit {id, action, index?}`; cells copied from a spreadsheet (tab-separated text, quoted cells kept whole) paste as a table, up to 50 × 20 with a note when there was more; undoable and synced in live sessions. No column resizing by dragging, no cell colours, no Tab between cells; comma-separated text pastes as text | | Mind maps | missing | | | Washi tape, code blocks, image cropping on boards | missing | images can be placed as in design files | | Audio and huddles | missing | | @@ -64,6 +64,7 @@ all end with the same geometry. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M12, gap 24: cells copied from a spreadsheet paste onto a board as a table. The clipboard carries them as text: a row per line, a tab between cells, and a cell holding a tab, a line break or a quote in quotes. `board_table::spreadsheet_cells` reads that into `board.table`'s `cells`. The table is centred in the view, or with Paste here its top-left goes at the point. Up to 50 × 20 cells are kept, with a note when there were more. Text without tabs, comma-separated text and any paste into a design file stay a text layer, as before. Tests: `board_table_paste_tests`, `table_paste_tests`. | | 2026-10-30 | minor | #1597: Shift+Cmd/Ctrl+Enter on a selected shape adds the next shape joined by a connector and starts typing in it; on a sticky it still adds a sticky beside it. Numbers unchanged. | | 2026-10-30 | minor | A row for creating the next shape already connected: "+" handles on a selected sticky's or shape's sides make the next one joined by a connector, ready for typing (#1597). Numbers unchanged | | 2026-10-30 | minor | M12, #1342: a FigJam `.jam` file opens as a board, our whiteboard file kind, instead of a design file: the `.fig` reader marks a canvas with FigJam's `fig-jam.` header as `FileKind::Board`, so the file opens with the board toolbar, the dot grid and the board panels, and saves as a board. A `.fig` stays a design file, and `file.import` of a `.jam` adds its pages to the open file without changing its kind. Its stickies and shapes with text are still frames with their generated layers and its connectors vectors, not native board objects. Tests: `board_import_tests.rs` (the kind, `.jam` against `.fig`), `jam_open_tests.rs` (open as a board, import into a design file). | diff --git a/docs/roadmap.md b/docs/roadmap.md index 07bfef39..a0b6e117 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-30 · **Change:** minor (a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); 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 @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M12, gap 24: cells copied from a spreadsheet paste onto a board as a table. The clipboard carries them as text: a row per line, a tab between cells, and a cell holding a tab, a line break or a quote in quotes. `board_table::spreadsheet_cells` reads that into `board.table`'s `cells`. The table is centred in the view, or with Paste here its top-left goes at the point. Up to 50 × 20 cells are kept, with a note when there were more. Text without tabs, comma-separated text and any paste into a design file stay a text layer, as before. Tests: `board_table_paste_tests`, `table_paste_tests`. | | 2026-10-30 | minor | M6, #1650: a very long frame name made the Export section's **Export <name>** button wrap its label into a tall block centred on the button, painted over Position, Layout, Appearance, Fill and the rest of the Design panel. The button now shows one line ending with an ellipsis, with the full name on hover, and line breaks in a name show as spaces. Dev Mode's header, which names the layer, does the same instead of running off the panel's edge. Shortening text to fit now halves its way to the longest fit instead of trying one candidate per character: with a 4,000-character name that kept frames at about 0.4 ms instead of about 9.5 ms, and it finds exactly what the old search found. | | 2026-10-30 | minor | M8, #1571 (part 5, fixes it): with 648 layers selected, the right panel cost 0.55 ms on every frame against 0.19 ms with one layer, and the canvas overlays 0.27 ms against 0.16. Each Design panel section went over the whole selection several times a frame for mixed values and which kinds are selected (about 20 passes, each looking up 648 layers in the document). The overlays did the same for boolean operand outlines and constraint guides. Now the panel looks the selection up once per document snapshot and selection; while a drag only moves the layers, that is the document the drag started from. `common` and a new `every` read those layers. The overlays keep which layers have constraint guides and which boolean operands to outline, worked out once per document and selection; during such a drag, from the start document, since a move changes neither. Measured on the same synthetic file of about 1,900 layers: the drag-all UI mean went from 1.48 to 1.04–1.09 ms per frame, and hovering with all selected from 0.95–1.04 to 0.64–0.69 ms. The right panel went to 0.25 ms and the overlays to 0.18 ms. Screenshots of the panel and the overlays are byte-identical before and after. Tests cover the kept layers after edits, another selection, a deleted layer and another document, and the overlays after structure edits. | | 2026-10-30 | minor | M6, gap 6: `image` controls in plugin panels (base64, 256 KB, 4,096 px a side; specs up to 1 MB), named by their label. Tests: `v2_tests`, `plugin_panel_tests` | From 7e9d6ce8c5a84e9241bb6f0edbd03cf179107762 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 22:50:20 +0700 Subject: [PATCH 09/10] M12: line ends, dashes, weight and any colour on a board connector's bar (#1596) (#1632) The bar over a selected connector adds start and end point menus with every line end, a line style menu (solid or dashed, weights 1 to 6) and a colour wheel opening the colour picker, beside the board colours and line type; every control names itself on hover. UI only, through board.setStyle and the stroke's dashes and weight. --- ROADMAP.md | 3 +- crates/ui-egui/locales/ar.toml | 1 - crates/ui-egui/locales/bn.toml | 1 - crates/ui-egui/locales/bs.toml | 1 - crates/ui-egui/locales/cs.toml | 1 - crates/ui-egui/locales/de.toml | 1 - crates/ui-egui/locales/en.toml | 1 - crates/ui-egui/locales/es.toml | 1 - crates/ui-egui/locales/fi.toml | 1 - crates/ui-egui/locales/fr.toml | 1 - crates/ui-egui/locales/id.toml | 1 - crates/ui-egui/locales/ja.toml | 1 - crates/ui-egui/locales/ka.toml | 1 - crates/ui-egui/locales/ko.toml | 1 - crates/ui-egui/locales/nl.toml | 1 - crates/ui-egui/locales/pl.toml | 1 - crates/ui-egui/locales/pt-BR.toml | 1 - crates/ui-egui/locales/ru.toml | 1 - crates/ui-egui/locales/sv.toml | 1 - crates/ui-egui/locales/th.toml | 1 - crates/ui-egui/locales/tr.toml | 1 - crates/ui-egui/locales/zh-Hans.toml | 1 - crates/ui-egui/src/board_line_tests.rs | 105 ++++++++++++++ crates/ui-egui/src/board_ui_tests.rs | 11 +- crates/ui-egui/src/canvas/board_bar.rs | 183 ++++++++++++++++++++++--- crates/ui-egui/src/lib.rs | 2 + docs/boards-parity.md | 5 +- docs/roadmap.md | 3 +- 28 files changed, 282 insertions(+), 51 deletions(-) create mode 100644 crates/ui-egui/src/board_line_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8117e954..43fabd59 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-30 · **Change:** minor (cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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 @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M12, #1596: the bar over a selected connector on a board had the line type and one arrow button. It now has, beside the board colours and the line type, a colour wheel opening the colour picker for any colour (when only connectors are selected), menus for the start and end points (every line end the Design panel offers, each shown), and a line style menu (solid or dashed, weights 1, 2, 4 and 6), each control naming itself on hover. The arrow toggle gave way to the end point menu. UI only: `board.setStyle` caps and the stroke's dashes and weight, one undo step each. Tests: `board_line_tests.rs` (headless). | | 2026-10-30 | minor | M12, gap 24: cells copied from a spreadsheet paste onto a board as a table. The clipboard carries them as text: a row per line, a tab between cells, and a cell holding a tab, a line break or a quote in quotes. `board_table::spreadsheet_cells` reads that into `board.table`'s `cells`. The table is centred in the view, or with Paste here its top-left goes at the point. Up to 50 × 20 cells are kept, with a note when there were more. Text without tabs, comma-separated text and any paste into a design file stay a text layer, as before. Tests: `board_table_paste_tests`, `table_paste_tests`. | | 2026-10-30 | minor | M6, #1650: a very long frame name made the Export section's **Export <name>** button wrap its label into a tall block centred on the button, painted over Position, Layout, Appearance, Fill and the rest of the Design panel. The button now shows one line ending with an ellipsis, with the full name on hover, and line breaks in a name show as spaces. Dev Mode's header, which names the layer, does the same instead of running off the panel's edge. Shortening text to fit now halves its way to the longest fit instead of trying one candidate per character: with a 4,000-character name that kept frames at about 0.4 ms instead of about 9.5 ms, and it finds exactly what the old search found. | | 2026-10-30 | minor | M8, #1571 (part 5, fixes it): with 648 layers selected, the right panel cost 0.55 ms on every frame against 0.19 ms with one layer, and the canvas overlays 0.27 ms against 0.16. Each Design panel section went over the whole selection several times a frame for mixed values and which kinds are selected (about 20 passes, each looking up 648 layers in the document). The overlays did the same for boolean operand outlines and constraint guides. Now the panel looks the selection up once per document snapshot and selection; while a drag only moves the layers, that is the document the drag started from. `common` and a new `every` read those layers. The overlays keep which layers have constraint guides and which boolean operands to outline, worked out once per document and selection; during such a drag, from the start document, since a move changes neither. Measured on the same synthetic file of about 1,900 layers: the drag-all UI mean went from 1.48 to 1.04–1.09 ms per frame, and hovering with all selected from 0.95–1.04 to 0.64–0.69 ms. The right panel went to 0.25 ms and the overlays to 0.18 ms. Screenshots of the panel and the overlays are byte-identical before and after. Tests cover the kept layers after edits, another selection, a deleted layer and another document, and the overlays after structure edits. | diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index 449a077b..d7ccd6be 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -145,7 +145,6 @@ status = "draft — needs native review" "Archived" = "مؤرشف" "Arrange" = "ترتيب" "Arrow" = "سهم" -"Arrow at the end" = "سهم عند النهاية" "As typed" = "كما أُدخل" "Asking the server…" = "جارٍ الاستعلام من الخادم…" "Assets" = "الأصول" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 4da435d3..1f27a73d 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -139,7 +139,6 @@ status = "draft — needs native review" "Archived" = "আর্কাইভ করা হয়েছে" "Arrange" = "সাজান" "Arrow" = "তীর" -"Arrow at the end" = "শেষে তীর" "As typed" = "যেমনটি টাইপ করা হয়েছে" "Asking the server…" = "সার্ভারকে জিজ্ঞাসা করা হচ্ছে..." "Assets" = "অ্যাসেট" diff --git a/crates/ui-egui/locales/bs.toml b/crates/ui-egui/locales/bs.toml index 909a0012..306d0522 100644 --- a/crates/ui-egui/locales/bs.toml +++ b/crates/ui-egui/locales/bs.toml @@ -143,7 +143,6 @@ status = "draft — needs native review" "Archived" = "Arhivirano" "Arrange" = "Rasporedi" "Arrow" = "Strelica" -"Arrow at the end" = "Strelica na kraju" "As typed" = "Kako je otkucano" "Asking the server…" = "Upit serveru…" "Assets" = "Resursi" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index ea7960af..0e8ead99 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -133,7 +133,6 @@ status = "draft — needs native review" "Archived" = "Archivováno" "Arrange" = "Uspořádat" "Arrow" = "Šipka" -"Arrow at the end" = "Šipka na konci" "As typed" = "Podle zadání" "Asking the server…" = "Dotazování serveru…" "Assets" = "Zdroje" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 1578e75d..55db171c 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -131,7 +131,6 @@ status = "draft — needs native review" "Archived" = "Archiviert" "Arrange" = "Anordnen" "Arrow" = "Pfeil" -"Arrow at the end" = "Pfeil am Ende" "As typed" = "Wie eingegeben" "Asking the server…" = "Server wird gefragt…" "Assets" = "Assets" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 17a1b99b..b648dfaa 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -141,7 +141,6 @@ name = "English" "Archived" = "Archived" "Arrange" = "Arrange" "Arrow" = "Arrow" -"Arrow at the end" = "Arrow at the end" "As typed" = "As typed" "Asking the server…" = "Asking the server…" "Assets" = "Assets" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 2f3602c2..8227f9b0 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -20,7 +20,6 @@ status = "draft — needs native review" "Along the right" = "A lo largo del borde derecho" "Along the top" = "A lo largo del borde superior" "Android Vector Drawable" = "Android Vector Drawable" -"Arrow at the end" = "Flecha al final" "Best effort" = "Mejor aproximación" "Between the selected layers" = "Entre las capas seleccionadas" "Brainstorm" = "Lluvia de ideas" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 12020ea8..bcfbfd8e 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -140,7 +140,6 @@ status = "draft — needs native review" "Archived" = "Arkistoitu" "Arrange" = "Järjestä" "Arrow" = "Nuoli" -"Arrow at the end" = "Nuoli loppuun" "As typed" = "Kirjoitetussa muodossa" "Asking the server…" = "Kysytään palvelimelta…" "Assets" = "Resurssit" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 5291647e..1159eea6 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -138,7 +138,6 @@ status = "draft — needs native review" "Archived" = "Archivée" "Arrange" = "Disposer" "Arrow" = "Flèche" -"Arrow at the end" = "Flèche à la fin" "As typed" = "Tel que saisi" "Asking the server…" = "Interrogation du serveur…" "Assets" = "Ressources" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index dc453d3b..20d7c83f 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -143,7 +143,6 @@ status = "draft — needs native review" "Archived" = "Diarsipkan" "Arrange" = "Susun" "Arrow" = "Panah" -"Arrow at the end" = "Panah di ujung" "As typed" = "Sesuai ketikan" "Asking the server…" = "Menghubungi server…" "Assets" = "Aset" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 80220c1a..b051fa24 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -139,7 +139,6 @@ status = "draft — needs native review" "Archived" = "アーカイブ済み" "Arrange" = "配置" "Arrow" = "矢印" -"Arrow at the end" = "末端に矢印" "As typed" = "入力どおり" "Asking the server…" = "サーバーに問い合わせ中…" "Assets" = "アセット" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 4e85201f..5f541169 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -141,7 +141,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Archived" = "დაარქივებულია" "Arrange" = "დალაგება" "Arrow" = "ისარი" -"Arrow at the end" = "ისარი ბოლოში" "As typed" = "როგორც აიკრიფა" "Asking the server…" = "სერვერთან მოთხოვნა…" "Assets" = "რესურსები" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index fbc6353d..583a6b4d 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -131,7 +131,6 @@ status = "draft — needs native review" "Archived" = "보관됨" "Arrange" = "배치" "Arrow" = "화살표" -"Arrow at the end" = "끝에 화살표" "As typed" = "입력한 대로" "Asking the server…" = "서버에 확인하는 중…" "Assets" = "에셋" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 5fc56d79..fc8fab85 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -140,7 +140,6 @@ status = "draft — needs native review" "Archived" = "Gearchiveerd" "Arrange" = "Schikken" "Arrow" = "Pijl" -"Arrow at the end" = "Pijl aan het eind" "As typed" = "Zoals getypt" "Asking the server…" = "Bezig met vragen aan de server…" "Assets" = "Assets" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 52a6c5d7..b1883d1f 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -138,7 +138,6 @@ name = "Polski" "Archived" = "Zarchiwizowano" "Arrange" = "Ułóż" "Arrow" = "Strzałka" -"Arrow at the end" = "Strzałka na końcu" "As typed" = "Zgodnie z wpisanym" "Asking the server…" = "Odpytywanie serwera…" "Assets" = "Zasoby" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index ae1ee18f..1b8cbe78 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -129,7 +129,6 @@ status = "draft — needs native review" "Archived" = "Arquivada" "Arrange" = "Organizar" "Arrow" = "Seta" -"Arrow at the end" = "Seta no final" "As typed" = "Como digitado" "Asking the server…" = "Consultando o servidor…" "Assets" = "Recursos" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 19283a45..5ebee589 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -138,7 +138,6 @@ name = "Русский" "Archived" = "В архиве" "Arrange" = "Упорядочить" "Arrow" = "Стрелка" -"Arrow at the end" = "Стрелка на конце" "As typed" = "Как введено" "Asking the server…" = "Запрос к серверу…" "Assets" = "Ресурсы" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index f3b7bb9a..40eccf53 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -141,7 +141,6 @@ name = "Svenska" "Archived" = "Arkiverad" "Arrange" = "Ordna" "Arrow" = "Pil" -"Arrow at the end" = "Pil i slutet" "As typed" = "Som inskrivet" "Asking the server…" = "Frågar servern…" "Assets" = "Resurser" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index 676ff55e..b2e821b4 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -139,7 +139,6 @@ status = "draft — needs native review" "Archived" = "เก็บถาวรแล้ว" "Arrange" = "จัดเรียง" "Arrow" = "ลูกศร" -"Arrow at the end" = "ลูกศรที่ปลาย" "As typed" = "ตามที่พิมพ์" "Asking the server…" = "กำลังถามเซิร์ฟเวอร์…" "Assets" = "แอสเซ็ต" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 1c78b81c..7a3a6c33 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -137,7 +137,6 @@ name = "Türkçe" "Archived" = "Arşivlendi" "Arrange" = "Konumlandır" "Arrow" = "Ok" -"Arrow at the end" = "Sonda ok" "As typed" = "Yazıldığı gibi" "Asking the server…" = "Sunucuya soruluyor…" "Assets" = "Varlıklar" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 6f628d92..a2969668 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -132,7 +132,6 @@ status = "draft — needs native review" "Archived" = "已归档" "Arrange" = "排列" "Arrow" = "箭头" -"Arrow at the end" = "末端箭头" "As typed" = "原样" "Asking the server…" = "正在询问服务器…" "Assets" = "资源" diff --git a/crates/ui-egui/src/board_line_tests.rs b/crates/ui-egui/src/board_line_tests.rs new file mode 100644 index 00000000..f7b1b1b4 --- /dev/null +++ b/crates/ui-egui/src/board_line_tests.rs @@ -0,0 +1,105 @@ +//! The bar over a board's selected connector (#1596), drawn headless: its line type, start and end +//! points, dashed or solid line and weight, and any colour through the colour picker as well as +//! the board colours; the colour wheel only when the selection is all connectors. + +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2}; +use figcraft_doc::{NodeId, Routing, StrokeCap}; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, has_text, text_center}; + +fn id(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn click(u: &mut Ui, p: Pos2) { + for pressed in [true, false] { + u.frame(vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + } + u.frame(vec![]); +} + +/// The bar over the selection, when it shows. +fn bar(u: &Ui) -> Option { + let layer = egui::LayerId::new(egui::Order::Middle, egui::Id::unique("board_bar")); + u.ctx.memory(|m| m.areas().visible_last_frame(&layer)).then(|| u.ctx.memory(|m| m.area_rect(layer.id))).flatten() +} + +/// A board with two stickies joined by a selected elbow connector. +fn connector() -> (Ui, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.newBoard", &json!({})).unwrap(); + let a = s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()["id"].clone(); + let b = s.execute("board.sticky", &json!({"x": 400, "y": 300})).unwrap()["id"].clone(); + let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "elbow"})).unwrap()); + let mut u = Ui::new(s); + u.frame(vec![]); + u.frame(vec![]); + (u, c, NodeId(b.as_u64().unwrap())) +} + +/// Open the menu at `x` from the bar's left edge and pick `item` in it. +fn pick(u: &mut Ui, x: f32, item: &str) { + let r = bar(u).expect("the bar shows"); + click(u, pos2(r.min.x + x, r.center().y)); + let shapes = flat(&u.frame_output(vec![]).shapes); + click(u, text_center(&shapes, item)); +} + +// The bar's parts from its left edge: 8 board colours from 8 (24 apart) and the colour wheel +// (to 224), Straight, Elbow and Curved from 232 (30 apart), the start point (62 wide) from 328, the +// end point from 392 and the line style from 462. +const START: f32 = 328.0 + 31.0; +const END: f32 = 392.0 + 31.0; +const STYLE: f32 = 462.0 + 20.0; +const WHEEL: f32 = 200.0 + 10.0; + +#[test] +fn the_connector_bar_sets_its_ends_dashes_and_weight() { + let (mut u, c, _) = connector(); + let stroke = |u: &Ui| u.app.session.doc().unwrap().node(c).unwrap().stroke.clone(); + assert_eq!((stroke(&u).cap_start, stroke(&u).cap_end), (StrokeCap::None, StrokeCap::TriangleArrow)); + pick(&mut u, START, "Circle"); + assert_eq!(stroke(&u).cap_start, StrokeCap::CircleFilled); + pick(&mut u, END, "Diamond"); + assert_eq!(stroke(&u).cap_end, StrokeCap::DiamondFilled); + pick(&mut u, STYLE, "Dash"); + assert!(!stroke(&u).dashes.is_empty(), "dashed"); + pick(&mut u, STYLE, "Stroke weight 4"); + assert_eq!(stroke(&u).weight, 4.0); + pick(&mut u, STYLE, "Solid"); + assert!(stroke(&u).dashes.is_empty(), "solid again, the weight kept"); + assert_eq!(stroke(&u).weight, 4.0); + // The line type cells still work, and each change is a step of its own. + let r = bar(&u).unwrap(); + click(&mut u, pos2(r.min.x + 232.0 + 14.0, r.center().y)); + let routing = |u: &Ui| match &u.app.session.doc().unwrap().node(c).unwrap().board { + Some(figcraft_doc::BoardItem::Connector(k)) => k.routing, + _ => panic!("not a connector"), + }; + assert_eq!(routing(&u), Routing::Straight); + u.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(routing(&u), Routing::Elbow); + assert_eq!(stroke(&u).weight, 4.0, "the weight is its own step"); +} + +#[test] +fn the_colour_wheel_opens_the_picker_for_connectors_only() { + let (mut u, c, b) = connector(); + let r = bar(&u).unwrap(); + click(&mut u, pos2(r.min.x + WHEEL, r.center().y)); + assert_eq!(u.app.ui.picker, Some(crate::colorpicker::PickerTarget::Paint { fills: false, index: 0, ids: vec![c] }), "the line's colour"); + crate::colorpicker::close(&mut u.app); + // With a sticky selected too, the bar has only the board colours: no wheel, and the line type + // cells start where the wheel was. + u.app.session.execute("select.set", &json!({"ids": [c, b]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(!has_text(&shapes, "Edit colour")); + let r = bar(&u).unwrap(); + click(&mut u, pos2(r.min.x + WHEEL, r.center().y)); + assert!(u.app.ui.picker.is_none(), "no wheel to open"); +} diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs index c507f411..dbaf46d2 100644 --- a/crates/ui-egui/src/board_ui_tests.rs +++ b/crates/ui-egui/src/board_ui_tests.rs @@ -5,7 +5,7 @@ //! and the marks design files and boards get. use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2}; -use figcraft_doc::{BoardItem, BoardShape, FileKind, NodeId, Routing, Stamp, StrokeCap, board_color}; +use figcraft_doc::{BoardItem, BoardShape, FileKind, NodeId, Routing, Stamp, board_color}; use figcraft_engine::Session; use figcraft_engine::tools::ToolId; use serde_json::{Value, json}; @@ -143,19 +143,16 @@ fn the_selection_bar_sets_a_connectors_style_and_offers_nothing_for_a_stamp() { u.frame(vec![]); let r = bar(&u).expect("the bar shows for a connector"); let cy = r.center().y; - // After the colours (208): Straight, Elbow, Curved, the end's arrow, 30 apart. - click(&mut u, pos2(r.min.x + 208.0 + 14.0, cy)); + // After the colours and the colour wheel (232): Straight, Elbow, Curved, 30 apart. + click(&mut u, pos2(r.min.x + 232.0 + 14.0, cy)); let routing = |u: &Ui| match &u.app.session.doc().unwrap().node(c).unwrap().board { Some(BoardItem::Connector(k)) => k.routing, _ => panic!("not a connector"), }; assert_eq!(routing(&u), Routing::Straight); let r = bar(&u).unwrap(); - click(&mut u, pos2(r.min.x + 208.0 + 60.0 + 14.0, cy)); + click(&mut u, pos2(r.min.x + 232.0 + 60.0 + 14.0, cy)); assert_eq!(routing(&u), Routing::Curved); - let r = bar(&u).unwrap(); - click(&mut u, pos2(r.min.x + 208.0 + 90.0 + 14.0, cy)); - assert_eq!(u.app.session.doc().unwrap().node(c).unwrap().stroke.cap_end, StrokeCap::None); // Nothing is offered while a stamp alone is selected: it takes neither colour nor style. let st = id(&u.app.session.execute("board.stamp", &json!({"x": 900, "y": 0, "stamp": "star"})).unwrap()); u.app.session.execute("select.set", &json!({"ids": [st]})).unwrap(); diff --git a/crates/ui-egui/src/canvas/board_bar.rs b/crates/ui-egui/src/canvas/board_bar.rs index 418731b5..69e6593c 100644 --- a/crates/ui-egui/src/canvas/board_bar.rs +++ b/crates/ui-egui/src/canvas/board_bar.rs @@ -3,7 +3,7 @@ //! board tools' flyouts share. use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; -use figcraft_doc::{BOARD_COLORS, BoardItem, BoardShape, NodeKind, Routing, Stamp, StrokeCap}; +use figcraft_doc::{BOARD_COLORS, BoardItem, BoardShape, NodeId, NodeKind, Routing, Stamp, StrokeCap}; use figcraft_engine::board::{BoardTool, TEXT_SIZES}; use figcraft_engine::board_grid::GridStyle; use serde_json::{Value, json}; @@ -96,16 +96,30 @@ struct Has { color: bool, shape: Option, text: bool, - connector: Option<(Routing, bool)>, + connector: Option, + /// Every selected board object is a connector: the colour part offers any colour too. + only_lines: bool, table: bool, } +/// The selected connectors' line as the bar shows it (the first one's), and their ids. +struct Line { + routing: Routing, + start: StrokeCap, + end: StrokeCap, + weight: f64, + dashed: bool, + ids: Vec, +} + /// What the selected board objects take from the bar (layers that aren't board objects, a section /// say, are left out); `None` when none takes anything. fn has(app: &FigApp) -> Option { let ds = app.session.ds().ok()?; let mut h = Has::default(); + let mut others = false; for n in ds.selection.iter().filter_map(|id| ds.doc.node(*id)) { + others |= n.board.is_some() && !matches!(n.board, Some(BoardItem::Connector(_))); match &n.board { Some(BoardItem::Sticky { .. } | BoardItem::Ink) => h.color = true, Some(BoardItem::Shape { shape }) => { @@ -115,13 +129,23 @@ fn has(app: &FigApp) -> Option { } Some(BoardItem::Connector(c)) => { h.color = true; - h.connector = Some((c.routing, n.stroke.cap_end != StrokeCap::None)); + let s = &n.stroke; + let line = h.connector.get_or_insert_with(|| Line { + routing: c.routing, + start: s.cap_start, + end: s.cap_end, + weight: s.weight, + dashed: !s.dashes.is_empty(), + ids: Vec::new(), + }); + line.ids.push(n.id); } Some(BoardItem::Label { .. }) => h.text = true, Some(BoardItem::Table) => h.table = true, Some(BoardItem::Stamp { .. }) | None => {} } } + h.only_lines = h.connector.is_some() && !others; (h.color || h.text || h.connector.is_some() || h.table).then_some(h) } @@ -137,6 +161,9 @@ impl Has { let mut w = 8.0; if self.color { w += BOARD_COLORS.len() as f32 * (SWATCH + GAP) + PART_END; + if self.only_lines { + w += SWATCH + GAP; + } } if self.shape.is_some() { w += CELL + 12.0 + GAP + PART_END; @@ -145,7 +172,7 @@ impl Has { w += TEXT_SIZES.len() as f32 * (CELL + 2.0) + PART_END; } if self.connector.is_some() { - w += 4.0 * (CELL + 2.0) + PART_END; + w += LINE_W; } if self.table { w += crate::board_picker::TABLE_PART_W; @@ -190,7 +217,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let cy = rect.center().y; let mut x = rect.min.x + 8.0; if h.color { - x = color_part(app, ui, x, cy, &t); + let lines = h.connector.as_ref().filter(|_| h.only_lines).map(|l| l.ids.as_slice()); + x = color_part(app, ui, x, cy, &t, lines); } if let Some(shape) = h.shape { x = shape_part(app, ui, shape, pos2(x, cy), &t); @@ -201,14 +229,15 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { if h.table { x = crate::board_picker::table_part(app, ui, x, cy, &t); } - if let Some((routing, arrow)) = h.connector { - line_part(app, ui, (routing, arrow), pos2(x, cy), &t); + if let Some(line) = &h.connector { + line_part(app, ui, line, pos2(x, cy), &t); } }); } -/// The board colours from `x` (`board.setColor`); where the next part starts. -fn color_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 { +/// The board colours from `x` (`board.setColor`), and for connectors `lines` any colour in the +/// colour picker; where the next part starts. +fn color_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens, lines: Option<&[NodeId]>) -> f32 { for (i, (name, c)) in BOARD_COLORS.iter().enumerate() { let r = Rect::from_center_size(pos2(x + SWATCH / 2.0, cy), vec2(SWATCH, SWATCH)); let resp = ui.interact(r, ui.scope_id().with(("bb_color", i)), Sense::click()).on_hover_text(tr(color_label(name))); @@ -222,9 +251,34 @@ fn color_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> } x += SWATCH + GAP; } + if let Some(ids) = lines { + let r = Rect::from_center_size(pos2(x + SWATCH / 2.0, cy), vec2(SWATCH, SWATCH)); + let resp = ui.interact(r, ui.scope_id().with("bb_color_any"), Sense::click()).on_hover_text(tr("Edit colour")); + a11y::button(&resp, tr("Edit colour")); + if resp.hovered() { + ui.painter().rect_filled(r.expand(2.0), CornerRadius::same(6), t.hover); + } + paint_wheel(ui.painter(), r.center(), SWATCH / 2.0 - 1.0); + app.picker_control(crate::colorpicker::PickerTarget::Paint { fills: false, index: 0, ids: ids.to_vec() }, &resp); + x += SWATCH + GAP; + } divider(ui, t, x, cy) } +/// A small colour wheel: the button for any colour. +fn paint_wheel(p: &egui::Painter, c: Pos2, r: f32) { + const STEPS: usize = 12; + let at = |i: usize| { + let a = i as f32 / STEPS as f32 * std::f32::consts::TAU; + c + vec2(a.cos(), a.sin()) * r + }; + for i in 0..STEPS { + let hue = egui::ecolor::Hsva::new(i as f32 / STEPS as f32, 0.75, 0.95, 1.0); + p.add(egui::Shape::convex_polygon(vec![c, at(i), at(i + 1)], Color32::from(hue), Stroke::NONE)); + } + p.circle_filled(c, r * 0.4, Color32::WHITE); +} + /// The shape menu at `at` (its left edge and middle), showing `shape`; where the next part starts. fn shape_part(app: &mut FigApp, ui: &mut Ui, shape: BoardShape, at: Pos2, t: &Tokens) -> f32 { let r = Rect::from_min_size(pos2(at.x, at.y - CELL / 2.0), vec2(CELL + 12.0, CELL)); @@ -267,23 +321,114 @@ fn size_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> divider(ui, t, x, cy) } -/// A connector's routing (straight, elbow, curved) and end arrow at `at`, as they are now (`line`). -fn line_part(app: &mut FigApp, ui: &mut Ui, line: (Routing, bool), at: Pos2, t: &Tokens) { - let (routing, arrow) = line; +/// A start or end point menu's width: the end's picture and the menu's chevron. +const CAP_W: f32 = 62.0; +/// The line style menu's width. +const STYLE_W: f32 = CELL + 12.0; +/// The connector part's width: the three routings, the two ends and the line style. +const LINE_W: f32 = 3.0 * (CELL + 2.0) + 8.0 + 2.0 * CAP_W + 2.0 + 8.0 + STYLE_W + PART_END; +/// The weights the line style menu offers. +const WEIGHTS: [f64; 4] = [1.0, 2.0, 4.0, 6.0]; +/// A dashed connector's dashes and gaps. +const DASHES: [f64; 2] = [8.0, 6.0]; + +/// A connector's routing (straight, elbow, curved), its start and end points and its line style at +/// `at`, as they are now (`line`): `board.setStyle`, and the stroke's dashes and weight. +fn line_part(app: &mut FigApp, ui: &mut Ui, line: &Line, at: Pos2, t: &Tokens) { let cells = [ - (Icon::Line, tr("Straight"), routing == Routing::Straight, json!({"style": "straight"})), - (Icon::Connector, tr("Elbow"), routing == Routing::Elbow, json!({"style": "elbow"})), - (Icon::CurvedConnector, tr("Curved"), routing == Routing::Curved, json!({"style": "curved"})), - (Icon::Arrow, tr("Arrow at the end"), arrow, json!({"endCap": if arrow { "none" } else { "triangleArrow" }})), + (Icon::Line, tr("Straight"), Routing::Straight, "straight"), + (Icon::Connector, tr("Elbow"), Routing::Elbow, "elbow"), + (Icon::CurvedConnector, tr("Curved"), Routing::Curved, "curved"), ]; let mut x = at.x; - for (i, (icon, tip, on, params)) in cells.into_iter().enumerate() { + for (i, (icon, tip, routing, id)) in cells.into_iter().enumerate() { let r = Rect::from_center_size(pos2(x + CELL / 2.0, at.y), vec2(CELL, CELL)); - if crate::toolbar::tool_cell(ui, t, r, ("bb_line", i), icon, on, tip).clicked() { - app.run("board.setStyle", params); + if crate::toolbar::tool_cell(ui, t, r, ("bb_line", i), icon, line.routing == routing, tip).clicked() { + app.run("board.setStyle", json!({"style": id})); } x += CELL + 2.0; } + x = divider(ui, t, x - 2.0, at.y); + for (start, cap) in [(true, line.start), (false, line.end)] { + cap_menu(app, ui, t, pos2(x, at.y), start, cap); + x += CAP_W + 2.0; + } + x = divider(ui, t, x - 2.0, at.y); + style_menu(app, ui, t, pos2(x, at.y), line); +} + +/// The menu of a connector's start (`start`) or end point at `at` (its left edge and middle), +/// showing `cap`. +fn cap_menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens, at: Pos2, start: bool, cap: StrokeCap) { + let r = Rect::from_min_size(pos2(at.x, at.y - CELL / 2.0), vec2(CAP_W, CELL)); + let label = if start { tr("Start point") } else { tr("End point") }; + let resp = ui.interact(r, ui.scope_id().with(("bb_cap", start)), Sense::click()).on_hover_text(label); + a11y::menu_button(&resp, label, a11y::menu_open(&resp)); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + // The picture ends 14 before the rectangle it is given: 4 in from this cell's left edge. + let art = Rect::from_min_max(pos2(r.min.x, r.min.y), pos2(r.min.x + 62.0, r.max.y)); + crate::right::cap_art(ui.painter(), art, t.icon, cap, start); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 7.0, at.y), vec2(12.0, 12.0)), Icon::Chevron, t.icon_secondary); + let key = if start { "startCap" } else { "endCap" }; + crate::right::menu_popup(&resp).show(|ui| { + for c in StrokeCap::ALL { + let art = |p: &egui::Painter, row: Rect, ink: Color32| crate::right::cap_art(p, row, ink, c, start); + if crate::right::menu_item_with(ui, tr(c.label()), "", crate::right::CAP_ART_W, art) { + app.run("board.setStyle", json!({key: c})); + ui.close(); + } + } + }); +} + +/// A line `weight` thick, dashed or not, across `r`'s middle. +fn paint_line(p: &egui::Painter, r: Rect, ink: Color32, weight: f64, dashed: bool) { + let w = (weight as f32).clamp(1.0, 5.0); + let ends = [pos2(r.min.x, r.center().y), pos2(r.max.x, r.center().y)]; + if dashed { + p.extend(egui::Shape::dashed_line(&ends, Stroke::new(w, ink), 5.0, 3.0)); + } else { + p.line_segment(ends, Stroke::new(w, ink)); + } +} + +/// The menu of the selected connectors' line style at `at`: solid or dashed, and its weight. +fn style_menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens, at: Pos2, line: &Line) { + let r = Rect::from_min_size(pos2(at.x, at.y - CELL / 2.0), vec2(STYLE_W, CELL)); + let label = tr("Stroke style"); + let resp = ui.interact(r, ui.scope_id().with("bb_line_style"), Sense::click()).on_hover_text(label); + a11y::menu_button(&resp, label, a11y::menu_open(&resp)); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); + } + let sample = Rect::from_min_max(pos2(r.min.x + 5.0, at.y), pos2(r.max.x - 15.0, at.y)); + paint_line(ui.painter(), sample, t.icon, line.weight, line.dashed); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 7.0, at.y), vec2(12.0, 12.0)), Icon::Chevron, t.icon_secondary); + let ids = line.ids.clone(); + crate::right::menu_popup(&resp).show(|ui| { + let art = |dashed: bool, weight: f64| { + move |p: &egui::Painter, row: Rect, ink: Color32| { + let r = Rect::from_min_max(pos2(row.max.x - 44.0, row.center().y), pos2(row.max.x - 12.0, row.center().y)); + paint_line(p, r, ink, weight, dashed); + } + }; + for (dashed, name) in [(false, tr("Solid")), (true, tr("Dash"))] { + if crate::right::menu_item_with(ui, name, "", 48.0, art(dashed, 2.0)) { + let dashes: &[f64] = if dashed { &DASHES } else { &[] }; + app.set_props(&ids, json!({"dashes": dashes})); + ui.close(); + } + } + ui.separator(); + for w in WEIGHTS { + if crate::right::menu_item_with(ui, &format!("{} {w}", tr("Stroke weight")), "", 48.0, art(false, w)) { + app.set_props(&ids, json!({"strokeWeight": w})); + ui.close(); + } + } + }); } /// A thin separator after `x`; where the next part starts. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 135e7b77..3464de0b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -39,6 +39,8 @@ pub mod windows; #[cfg(test)] mod board_connect_tests; #[cfg(test)] +mod board_line_tests; +#[cfg(test)] mod board_picker_tests; #[cfg(test)] mod board_ui_tests; diff --git a/docs/boards-parity.md b/docs/boards-parity.md index 8f2cd3fc..df946a0f 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,6 +1,6 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-30 · **Change:** minor (cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-30 · **Change:** minor (a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) **Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind (`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that @@ -28,7 +28,7 @@ all end with the same geometry. |---|---|---| | Board files: File ▸ New board, the home screen's New board card, a light page | done | `file.newBoard`; the kind shows in `inspect_document`, tabs and file rows (a note mark; design files a pen nib) | | Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Actions, then the templates and the Design panel's buttons — on the same 32 pt grid as a design file's (no Resources button, as there) | -| Design panel hidden by default; a contextual bar above the selection | done | colour, shape, text size (S/M/L/XL), connector straight/elbow/curved and its arrow; the Design panel opens with its button or View ▸ Design panel | +| Design panel hidden by default; a contextual bar above the selection | done | colour, shape, text size (S/M/L/XL); for connectors straight/elbow/curved, the start and end points (none, round, square, line arrow, triangle arrow, reversed triangle, circle, diamond), solid or dashed, the weight, and any colour in the colour picker beside the board colours (#1596); each control says what it is on hover; the Design panel opens with its button or View ▸ Design panel | | Prototyping and Dev Mode refuse on boards | done | `proto.*`, `dev.*`, `view.prototype`, `view.devMode` say why | | Sticky notes | done | a square frame (200 wide, growing taller through auto layout), 8 soft colours, a soft shadow, the note's text size shrinking as it grows (24 → 11, by characters and lines), the author's name at the bottom; a click places one and starts typing; Enter or a double-click edits; Shift+Cmd/Ctrl+Enter adds the next one beside it | | Sticky options: hide the author, change size presets, sticky stacks | partial | `board.sticky {author: false}`; no size presets or stacks | @@ -64,6 +64,7 @@ all end with the same geometry. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | The contextual bar row: a selected connector's bar sets its start and end points, solid or dashed, its weight and any colour, beside the line type and the board colours (#1596). Numbers unchanged | | 2026-10-30 | minor | M12, gap 24: cells copied from a spreadsheet paste onto a board as a table. The clipboard carries them as text: a row per line, a tab between cells, and a cell holding a tab, a line break or a quote in quotes. `board_table::spreadsheet_cells` reads that into `board.table`'s `cells`. The table is centred in the view, or with Paste here its top-left goes at the point. Up to 50 × 20 cells are kept, with a note when there were more. Text without tabs, comma-separated text and any paste into a design file stay a text layer, as before. Tests: `board_table_paste_tests`, `table_paste_tests`. | | 2026-10-30 | minor | #1597: Shift+Cmd/Ctrl+Enter on a selected shape adds the next shape joined by a connector and starts typing in it; on a sticky it still adds a sticky beside it. Numbers unchanged. | | 2026-10-30 | minor | A row for creating the next shape already connected: "+" handles on a selected sticky's or shape's sides make the next one joined by a connector, ready for typing (#1597). Numbers unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index a0b6e117..cc0eeef5 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-30 · **Change:** minor (cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); 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 @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M12, #1596: the bar over a selected connector on a board had the line type and one arrow button. It now has, beside the board colours and the line type, a colour wheel opening the colour picker for any colour (when only connectors are selected), menus for the start and end points (every line end the Design panel offers, each shown), and a line style menu (solid or dashed, weights 1, 2, 4 and 6), each control naming itself on hover. The arrow toggle gave way to the end point menu. UI only: `board.setStyle` caps and the stroke's dashes and weight, one undo step each. Tests: `board_line_tests.rs` (headless). | | 2026-10-30 | minor | M12, gap 24: cells copied from a spreadsheet paste onto a board as a table. The clipboard carries them as text: a row per line, a tab between cells, and a cell holding a tab, a line break or a quote in quotes. `board_table::spreadsheet_cells` reads that into `board.table`'s `cells`. The table is centred in the view, or with Paste here its top-left goes at the point. Up to 50 × 20 cells are kept, with a note when there were more. Text without tabs, comma-separated text and any paste into a design file stay a text layer, as before. Tests: `board_table_paste_tests`, `table_paste_tests`. | | 2026-10-30 | minor | M6, #1650: a very long frame name made the Export section's **Export <name>** button wrap its label into a tall block centred on the button, painted over Position, Layout, Appearance, Fill and the rest of the Design panel. The button now shows one line ending with an ellipsis, with the full name on hover, and line breaks in a name show as spaces. Dev Mode's header, which names the layer, does the same instead of running off the panel's edge. Shortening text to fit now halves its way to the longest fit instead of trying one candidate per character: with a 4,000-character name that kept frames at about 0.4 ms instead of about 9.5 ms, and it finds exactly what the old search found. | | 2026-10-30 | minor | M8, #1571 (part 5, fixes it): with 648 layers selected, the right panel cost 0.55 ms on every frame against 0.19 ms with one layer, and the canvas overlays 0.27 ms against 0.16. Each Design panel section went over the whole selection several times a frame for mixed values and which kinds are selected (about 20 passes, each looking up 648 layers in the document). The overlays did the same for boolean operand outlines and constraint guides. Now the panel looks the selection up once per document snapshot and selection; while a drag only moves the layers, that is the document the drag started from. `common` and a new `every` read those layers. The overlays keep which layers have constraint guides and which boolean operands to outline, worked out once per document and selection; during such a drag, from the start document, since a move changes neither. Measured on the same synthetic file of about 1,900 layers: the drag-all UI mean went from 1.48 to 1.04–1.09 ms per frame, and hovering with all selected from 0.95–1.04 to 0.64–0.69 ms. The right panel went to 0.25 ms and the overlays to 0.18 ms. Screenshots of the panel and the overlays are byte-identical before and after. Tests cover the kept layers after edits, another selection, a deleted layer and another document, and the overlays after structure edits. | From b3ca228a75702ee13fa206224d637c17933686df Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 23:05:57 +0700 Subject: [PATCH 10/10] M5: skipping the Open link notice by command is saved at once (#1667) proto.linkNotice from the Actions palette, the control channel or MCP changed the preference in memory only until the app next saved its preferences. It now sets Session::prefs_unsaved, which the app saves at the end of the frame like installed plugins and live-session tokens; the notice's own Don't show this again goes the same way. Gap 15. --- ROADMAP.md | 3 ++- crates/engine/src/cmd/proto.rs | 1 + crates/engine/src/lib.rs | 3 +++ crates/engine/src/session/new.rs | 1 + crates/ui-egui/src/app.rs | 7 +++++-- crates/ui-egui/src/present/chrome.rs | 1 - crates/ui-egui/src/present_links_tests.rs | 21 +++++++++++++++++++++ docs/gaps.md | 4 +--- 8 files changed, 34 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 43fabd59..a21c28e3 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-30 · **Change:** minor (a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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 @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | patch | M5, gap 15: skipping the Open link notice with `proto.linkNotice` (the Actions palette, the control channel, MCP) changed the preference in memory only, so it was lost if the app crashed before quitting. A command can now mark the preferences to be kept at once (`Session::prefs_unsaved`), which the app saves at the end of the frame like installed plugins and live-session tokens; `proto.linkNotice` does, and the notice's own "Don't show this again" goes the same way. Numbers unchanged. Test: `present_links_tests::skipping_the_notice_by_command_is_saved_at_once`. | | 2026-10-30 | minor | M12, #1596: the bar over a selected connector on a board had the line type and one arrow button. It now has, beside the board colours and the line type, a colour wheel opening the colour picker for any colour (when only connectors are selected), menus for the start and end points (every line end the Design panel offers, each shown), and a line style menu (solid or dashed, weights 1, 2, 4 and 6), each control naming itself on hover. The arrow toggle gave way to the end point menu. UI only: `board.setStyle` caps and the stroke's dashes and weight, one undo step each. Tests: `board_line_tests.rs` (headless). | | 2026-10-30 | minor | M12, gap 24: cells copied from a spreadsheet paste onto a board as a table. The clipboard carries them as text: a row per line, a tab between cells, and a cell holding a tab, a line break or a quote in quotes. `board_table::spreadsheet_cells` reads that into `board.table`'s `cells`. The table is centred in the view, or with Paste here its top-left goes at the point. Up to 50 × 20 cells are kept, with a note when there were more. Text without tabs, comma-separated text and any paste into a design file stay a text layer, as before. Tests: `board_table_paste_tests`, `table_paste_tests`. | | 2026-10-30 | minor | M6, #1650: a very long frame name made the Export section's **Export <name>** button wrap its label into a tall block centred on the button, painted over Position, Layout, Appearance, Fill and the rest of the Design panel. The button now shows one line ending with an ellipsis, with the full name on hover, and line breaks in a name show as spaces. Dev Mode's header, which names the layer, does the same instead of running off the panel's edge. Shortening text to fit now halves its way to the longest fit instead of trying one candidate per character: with a 4,000-character name that kept frames at about 0.4 ms instead of about 9.5 ms, and it finds exactly what the old search found. | diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index a620b037..7338facf 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -166,6 +166,7 @@ pub fn specs() -> Vec { |s, p| { let show = boolean(p, "show").unwrap_or(true); s.prefs.link_notice_skipped = !show; + s.prefs_unsaved = true; Ok(json!({"shown": show})) } ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a66c414d..69c2332b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -119,6 +119,9 @@ pub struct Session { pub active: usize, pub clipboard: Clipboard, pub prefs: Prefs, + /// A command changed a preference that is kept at once (not only when the app quits): the app + /// saves the preferences and clears this. + pub prefs_unsaved: bool, pub tool: tools::ToolState, pub ui_requests: Vec, /// The interface's theme once the UI knows it ([`Session::set_ui_dark`]): new files get the diff --git a/crates/engine/src/session/new.rs b/crates/engine/src/session/new.rs index 2e7c4374..c7eee09f 100644 --- a/crates/engine/src/session/new.rs +++ b/crates/engine/src/session/new.rs @@ -15,6 +15,7 @@ impl Session { active: 0, clipboard: Clipboard::default(), prefs: Prefs::default(), + prefs_unsaved: false, tool: tools::ToolState::default(), ui_requests: Vec::new(), ui_dark: None, diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index be845237..ded386c3 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -185,8 +185,11 @@ impl FigApp { // already happened and would otherwise duplicate). self.end_input_frame(&ctx); // Plugins installed, removed or set up are kept at once: a crash doesn't lose them. - // So are the owner tokens of files put online. - if std::mem::take(&mut self.session.plugins.unsaved) | std::mem::take(&mut self.session.live.unsaved) { + // So are the owner tokens of files put online, and preferences a command keeps at once. + if std::mem::take(&mut self.session.plugins.unsaved) + | std::mem::take(&mut self.session.live.unsaved) + | std::mem::take(&mut self.session.prefs_unsaved) + { self.services.save_prefs(&self.session.prefs); } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/present/chrome.rs b/crates/ui-egui/src/present/chrome.rs index bd74b5ac..ca54fcab 100644 --- a/crates/ui-egui/src/present/chrome.rs +++ b/crates/ui-egui/src/present/chrome.rs @@ -302,7 +302,6 @@ fn link_notice(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context, id: Id) let Some((url, skip)) = p.link_notice.take() else { return }; if skip == Some(true) { app.run("proto.linkNotice", json!({"show": false})); - app.services.save_prefs(&app.session.prefs); } app.services.open_url(&url); } else if cancel { diff --git a/crates/ui-egui/src/present_links_tests.rs b/crates/ui-egui/src/present_links_tests.rs index 39137fca..2484c0ca 100644 --- a/crates/ui-egui/src/present_links_tests.rs +++ b/crates/ui-egui/src/present_links_tests.rs @@ -355,6 +355,27 @@ fn dont_show_this_again_is_saved_with_the_preferences() { assert!(!u.app.session.prefs.link_notice_skipped); } +/// Skipping the notice with the command (the Actions palette, the control channel, MCP) is saved +/// at once as well, not only when the app quits, and a new session started from the saved +/// preferences skips it. +#[test] +fn skipping_the_notice_by_command_is_saved_at_once() { + let (mut u, seen) = playing("proto.present", false, json!({"trigger": {"type": "onClick"}}), &["https://example.com/a"]); + let before = seen.1.lock().unwrap().len(); + assert_eq!(u.run("proto.linkNotice", json!({"show": false})), json!({"shown": false})); + u.frames(1, Vec::new); + let saves = seen.1.lock().unwrap().clone(); + assert_eq!(saves.len(), before + 1, "saved once"); + let saved = saves.last().unwrap(); + assert!(saved.link_notice_skipped, "the choice is saved"); + // As the app keeps them: JSON, read back at the next start. + let mut next = Session::new(); + next.prefs = serde_json::from_value(serde_json::to_value(saved).unwrap()).unwrap(); + assert!(next.prefs.link_notice_skipped, "a new session reads it back"); + u.frames(1, Vec::new); + assert_eq!(seen.1.lock().unwrap().len(), before + 1, "and not saved again every frame"); +} + #[test] fn a_prototype_cannot_open_a_script_or_handler_url() { for url in [ diff --git a/docs/gaps.md b/docs/gaps.md index 13c4130b..8540d461 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -416,9 +416,6 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe importer warns and skips them, `crates/format/src/rest/proto.rs`); controllers on macOS, Linux and FreeBSD (the usual Rust controller library links libudev on Linux; a pure-Rust reader per platform, or an owner decision on that dependency); PlayStation and Switch button labels. - `proto.linkNotice` from the Actions palette or the control channel changes the preference in - memory only; it reaches the preferences file with the next preferences save (the notice's own - "Don't show this again" saves at once). - **Estimate:** 2–5 h, the media part after video fills (gap 12). ### 16. Dev Mode: the tail after dev resources @@ -576,6 +573,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-30 | patch | Gap 15: `proto.linkNotice` (the Actions palette, the control channel, MCP) is saved with the preferences at once, as the notice's own "Don't show this again" is, instead of only when the app quits; that note leaves the missing list. Estimate unchanged. Test: `present_links_tests::skipping_the_notice_by_command_is_saved_at_once` | | 2026-10-30 | minor | Gap 6: plugin panels show pictures: an `image` control (`data` base64, at most 256 KB and 4,096 px a side; panel specs up to 1 MB) drawn at its size up to a field's width, named by its label, which shows instead when the picture can't be decoded. "Images" leaves the missing list; estimate unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::panel_images_show_named_or_fall_back_to_their_label` | | 2026-10-30 | minor | Gap 1: labels of connectors without a saved snapshot are kept, centred on their line (without their background), instead of skipped. Not checked on real files; estimate unchanged. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` | | 2026-10-30 | minor | Gap 6: widget hover styles landed (a layer's `hover` fill, stroke, opacity or text colour shown while the pointer is over it, as a live preview; `widget.hover`); the entry no longer lists hover. Estimate unchanged. |