From 99440fa9ff58b693107d0d558be2971e61a0c5f7 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 16:13:55 +0700 Subject: [PATCH] M8: a drag that only moves the selection shifts what was worked out at its start (#1571) (#1640) The engine notes when a drag has only moved the selected layers by one offset (Interaction::moved_by); the canvas keeps the outlines, box and slots from the drag's start document and shifts them, the selection spacing and the Design panel's values (but the position fields) come from that document, and the Export section's values are kept. Dragging 648 layers: UI pass 2.10 to 1.44 ms a frame. --- ROADMAP.md | 3 +- crates/engine/src/canvas/tools/drag.rs | 24 +++++ crates/engine/src/doc_state.rs | 4 + crates/engine/src/lib.rs | 2 + crates/engine/src/moved_by_tests.rs | 87 +++++++++++++++++++ crates/engine/src/session/undo.rs | 2 +- crates/ui-egui/src/canvas/outline.rs | 5 +- crates/ui-egui/src/canvas/overlays.rs | 32 ++++++- .../src/canvas/selection_cache_tests.rs | 61 +++++++++++++ .../ui-egui/src/canvas/selection_spacing.rs | 14 +-- crates/ui-egui/src/panels/export_panel.rs | 51 +++++++---- crates/ui-egui/src/panels/right.rs | 41 +++++---- crates/ui-egui/src/selection_cache.rs | 10 +++ docs/roadmap.md | 3 +- 14 files changed, 292 insertions(+), 47 deletions(-) create mode 100644 crates/engine/src/moved_by_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 614856bba..0da9af23d 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 (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 (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 @@ -356,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1571 (part 3): the first frame after each move of a drag rebuilt the selection's outlines and box, its slots, the selection spacing and the Design panel's values from the new document. Now the engine notes when a drag has only moved the selected layers, each by the same offset from where it started (`Interaction::moved_by`: not for a layer in an auto layout flow, nor an Alt copy), and the canvas and panel work those out once from the document the drag started from and shift what depends on position (the outlines on screen, the box); the Design panel shows that document's values, all but the position fields, which follow the move. The Export section's values are kept too. Nothing looks different. Dragging 648 of about 1,900 synthetic layers (release build, two runs each): the UI pass 2.10 to 1.44 ms a frame, CPU 135% to 117% of a core; the Design panel 0.9 to 0.5 ms, the overlays 0.5 to 0.3 ms. Not yet: the engine's preview of each move (copying and normalising the document) and the canvas texture's whole upload. Tests: `moved_by_tests.rs`, `canvas/selection_cache_tests.rs`. | | 2026-10-30 | minor | M12, #1597 (the third point): Shift+Cmd/Ctrl+Enter (`board.nextSticky`) now works on a selected or edited shape too: it adds the next shape of the same kind, size and colour on its right (or the `side` given), joined to it by a connector and with typing going on in it, as the "+" beside it does (`board.addConnected`, #1631). On a sticky it adds a sticky beside it without a connector, as before. Numbers unchanged. Tests: `board_connected_tests::the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_were`, `board_ui_tests::the_next_shape_from_the_keyboard_comes_connected_and_goes_on_typing`. | | 2026-10-30 | minor | M12, #1597: boards create the next shape already connected. A selected sticky or shape shows a "+" beside each of its four sides (clear of the contextual bar, the size label and the toolbar); hovering one shows where the next goes and the connector to it; clicking makes a sticky, or a shape of the same kind, size and colour, 80 units away on that side, joined to it by a connector the way the Connector tool makes one, and starts typing in it. One undo step takes both away. `board.addConnected {id?, side?, edit?}` does it from commands, MCP and plugins. Shift+Cmd/Ctrl+Enter still adds a sticky without a connector. Tests: `board_connected_tests.rs` (engine), `board_connect_tests.rs` (the handles, headless). | | 2026-10-30 | minor | M4, #1292: a `.fig` import said a family was not installed and drew it in a fallback font although the font picker listed it. Fonts name a family two ways: a typographic name ("Economia", style "Medium") and a legacy name per weight ("Economia Medium", style "Regular"); fonts without typographic names are installed under the legacy ones only, while a file may use either. The font database now resolves a family + style either way (`FontDb::resolve`, used by every lookup: import, paste, REST import, rendering, the missing-fonts check and the font report): a family named with weight words finds the family without them, a style the family lacks finds the family named with it, and the Style list offers those weights. Family names also match across case, spaces, hyphens and underscores, and a weight number style ("600") goes by that weight. `.fig` and REST imports wait for the installed fonts to be indexed before their text is laid out. Widths aren't taken for weights (a missing "Roboto Condensed" stays missing). Tests: `fontdb_names_tests.rs` (synthetic names), `fig_font_names_tests.rs` (a `.fig` round trip). | diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs index e0f56a10a..aad28466f 100644 --- a/crates/engine/src/canvas/tools/drag.rs +++ b/crates/engine/src/canvas/tools/drag.rs @@ -232,6 +232,8 @@ impl Session { let mut in_flow = false; // Placed in a row or column: (is it a column, its gap) for the insertion line. let mut slot_axis: Option<(bool, f64)> = None; + // Only the selected layers moved, each by `d` (see `Interaction::moved_by`). + let mut shifted = !dup; self.preview(|doc, sel| { let mut moving = ids.clone(); if dup { @@ -288,6 +290,7 @@ impl Session { } // A free move. Layers dragged out of an auto layout flow leave it (onto the page // while dragging; the drop puts them in the frame under the pointer). + shifted &= only_shifted(doc, &moving); for m in &moving { let in_al = doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); if flow && in_al { @@ -298,6 +301,9 @@ impl Session { drop_target = (flow && target != page).then_some(target); Ok(()) })?; + if let Some(it) = self.ds_mut()?.interaction.as_mut() { + it.moved_by = (shifted && !in_flow).then_some((d.x, d.y)); + } let placed = if dup { self.selection() } else { ids }; // Snapping guides mean nothing while the layout places the layers; with Alt held, // live distances to the neighbours replace them. @@ -628,3 +634,21 @@ impl Session { Ok(()) } } + +/// Moving `moving` by the same offset moves nothing else and changes nothing about them but where +/// they are: none is inside another (it would move twice) or placed by its parent's auto layout +/// (the layout would put it back, or close the gap it leaves). +fn only_shifted(doc: &Document, moving: &[NodeId]) -> bool { + let set: std::collections::HashSet = moving.iter().copied().collect(); + moving.iter().all(|m| { + let Some(n) = doc.node(*m) else { return false }; + let in_flow = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute; + let mut up = n.parent; + let mut nested = false; + while let Some(p) = up { + nested |= set.contains(&p); + up = doc.node(p).and_then(|x| x.parent); + } + !in_flow && !nested + }) +} diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index ba412ba69..3a1f72e50 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -26,6 +26,10 @@ pub struct Interaction { pub label: String, pub doc: Arc, pub selection: Vec, + /// The gesture so far only moved the selected layers by this much (page units) from `doc`: + /// their outlines, box and everything else that doesn't depend on where they are can follow + /// by shifting what was worked out from `doc` (#1571). `None` for any other change. + pub moved_by: Option<(f64, f64)>, } /// The text being edited on the canvas. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e1f4c785a..7c73f65bc 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 moved_by_tests; +#[cfg(test)] mod nested_override_tests; #[cfg(test)] mod node_find_tests; diff --git a/crates/engine/src/moved_by_tests.rs b/crates/engine/src/moved_by_tests.rs new file mode 100644 index 000000000..5e8a9d451 --- /dev/null +++ b/crates/engine/src/moved_by_tests.rs @@ -0,0 +1,87 @@ +//! `Interaction::moved_by` (#1571): set while a drag has only moved the selected layers, each by the +//! same offset from the document the gesture started from, so the UI can shift what it worked out +//! from that document; `None` for anything else (a layer in an auto layout flow, a copy made with +//! Alt), and gone with the gesture. + +use figcraft_doc::NodeId; +use figcraft_geom::Point; +use serde_json::json; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind}; + +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() +} + +fn ev(kind: PointerKind, x: f64, y: f64, alt: bool) -> PointerEvent { + PointerEvent { alt, ..PointerEvent::at(kind, x, y, 1.0) } +} + +/// Press at `from` and move to `to`, holding the button: what the interaction says of it. +fn moved_by(s: &mut Session, from: (f64, f64), to: (f64, f64), alt: bool) -> Option<(f64, f64)> { + s.pointer(&ev(PointerKind::Down, from.0, from.1, alt)).unwrap(); + s.pointer(&ev(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, alt)).unwrap(); + s.pointer(&ev(PointerKind::Move, to.0, to.1, alt)).unwrap(); + s.ds().unwrap().interaction.as_ref().and_then(|it| it.moved_by) +} + +fn release(s: &mut Session, at: (f64, f64)) { + s.pointer(&ev(PointerKind::Up, at.0, at.1, false)).unwrap(); +} + +fn world(s: &Session, id: NodeId) -> Point { + let t = s.doc().unwrap().world_transform(id).translation(); + Point::new(t.x, t.y) +} + +#[test] +fn a_plain_move_says_how_far_and_matches_the_document() { + let mut s = Session::new(); + let a = rect(&mut s, None, 0.0, 0.0); + let b = rect(&mut s, None, 100.0, 0.0); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let (before_a, before_b) = (world(&s, a), world(&s, b)); + let d = moved_by(&mut s, (20.0, 20.0), (57.0, 81.0), false).expect("a plain move"); + assert_eq!(d, (37.0, 61.0)); + for (id, was) in [(a, before_a), (b, before_b)] { + assert_eq!(world(&s, id), Point::new(was.x + d.0, was.y + d.1), "each layer moved by it"); + } + release(&mut s, (57.0, 81.0)); + assert!(s.ds().unwrap().interaction.is_none(), "gone with the gesture"); +} + +#[test] +fn a_layer_selected_with_its_frame_moves_with_it_once() { + let mut s = Session::new(); + let g = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap()["id"]).unwrap(); + let inner = rect(&mut s, Some(g), 20.0, 20.0); + s.execute("select.set", &json!({"ids": [g, inner]})).unwrap(); + let was = world(&s, inner); + let d = moved_by(&mut s, (25.0, 25.0), (80.0, 90.0), false).expect("the frame moves, and the layer in it with it"); + assert_eq!(world(&s, inner), Point::new(was.x + d.0, was.y + d.1)); +} + +#[test] +fn other_moves_say_nothing() { + // A layer placed by its frame's auto layout. + let mut s = Session::new(); + let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 300})).unwrap()["id"]).unwrap(); + let a = rect(&mut s, Some(f), 10.0, 10.0); + rect(&mut s, Some(f), 60.0, 10.0); + s.execute("layout.add", &json!({"ids": [f]})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let at = world(&s, a); + assert_eq!(moved_by(&mut s, (at.x + 5.0, at.y + 5.0), (at.x + 9.0, at.y + 200.0), false), None, "in an auto layout flow"); + release(&mut s, (at.x + 9.0, at.y + 200.0)); + // A copy dragged out with Alt. + let mut s = Session::new(); + let a = rect(&mut s, None, 0.0, 0.0); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + assert_eq!(moved_by(&mut s, (20.0, 20.0), (90.0, 20.0), true), None, "a copy"); +} diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs index 4aa847603..fdf33b91f 100644 --- a/crates/engine/src/session/undo.rs +++ b/crates/engine/src/session/undo.rs @@ -57,7 +57,7 @@ impl Session { return Err(EngineError::Disabled(label.to_string(), PREVIEW_READ_ONLY.into())); } if ds.interaction.is_none() { - ds.interaction = Some(Interaction { label: label.to_string(), doc: ds.doc.clone(), selection: ds.selection.clone() }); + ds.interaction = Some(Interaction { label: label.to_string(), doc: ds.doc.clone(), selection: ds.selection.clone(), moved_by: None }); } Ok(()) } diff --git a/crates/ui-egui/src/canvas/outline.rs b/crates/ui-egui/src/canvas/outline.rs index 154a4de01..37e7aebc2 100644 --- a/crates/ui-egui/src/canvas/outline.rs +++ b/crates/ui-egui/src/canvas/outline.rs @@ -73,7 +73,10 @@ pub(super) fn quad(painter: &egui::Painter, q: [Pos2; 4], s: Stroke) { /// content). pub(super) fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Arc, sel: &[NodeId], drop: Option) { let key = [drop.map_or(0, |d| d.0.wrapping_add(1))]; - let slots = crate::selection_cache::cached(painter.ctx(), "slots", doc, sel, &key, || slots_shown(doc, sel, drop)); + // Which slots show doesn't change while the selection is only moved: the gesture's start keeps it. + let base = app.session.ds().ok().and_then(crate::selection_cache::moved).map(|(b, _)| b); + let from = base.as_ref().unwrap_or(doc); + let slots = crate::selection_cache::cached(painter.ctx(), "slots", from, sel, &key, || slots_shown(from, sel, drop)); for (id, name) in slots { let Some(q) = node_quad(app, doc, id) else { continue }; if !Rect::from_points(&q).intersects(app.canvas.rect) { diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index e34dc341b..ad293cb6a 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -268,6 +268,25 @@ pub(super) fn selection_geometry(app: &FigApp, painter: &egui::Painter, doc: &Ar }) } +/// [`selection_geometry`] as the canvas shows it now, and how far to shift it (on screen, on the +/// page): while a gesture has only moved the selection, the geometry where it started (kept for +/// the whole gesture) shifted by the move ([`crate::selection_cache::moved`]). +fn shown_geometry( + app: &FigApp, + painter: &egui::Painter, + ds: &figcraft_engine::DocState, + doc: &Arc, + sel: &[NodeId], +) -> (SelectionGeometry, egui::Vec2, figcraft_geom::Vec2) { + match crate::selection_cache::moved(ds) { + Some((base, (dx, dy))) => { + let on_screen = app.to_screen(dx, dy) - app.to_screen(0.0, 0.0); + (selection_geometry(app, painter, &base, sel), on_screen, figcraft_geom::Vec2::new(dx, dy)) + } + None => (selection_geometry(app, painter, doc, sel), egui::Vec2::ZERO, figcraft_geom::Vec2::ZERO), + } +} + /// The selection's outlines, transform box and handles; returns the size label's box. fn selection_overlay( app: &FigApp, @@ -278,9 +297,16 @@ fn selection_overlay( zoom: f64, ) -> Option { let mut size_label = None; - let geo = selection_geometry(app, painter, doc, sel); + let (geo, shift, page_shift) = shown_geometry(app, painter, ds, doc, sel); let col = geo.col; - painter.extend(geo.outlines.iter().cloned()); + if shift == egui::Vec2::ZERO { + painter.extend(geo.outlines.iter().cloned()); + } else { + painter.extend(geo.outlines.iter().cloned().map(|mut s| { + s.translate(shift); + s + })); + } // 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() { @@ -288,7 +314,7 @@ fn selection_overlay( } // The shape builder reshapes by regions: no transform box. if app.session.tool.tool != ToolId::ShapeBuilder - && let Some(b) = geo.bbox + && let Some(b) = geo.bbox.map(|b| b.map(|p| p + page_shift)) && let Some((w, h)) = geo.size { let is_line = sel.len() == 1 && matches!(sel.first().and_then(|i| doc.node(*i)).map(|n| &n.kind), Some(NodeKind::Line)); diff --git a/crates/ui-egui/src/canvas/selection_cache_tests.rs b/crates/ui-egui/src/canvas/selection_cache_tests.rs index ca20e5447..49427ce26 100644 --- a/crates/ui-egui/src/canvas/selection_cache_tests.rs +++ b/crates/ui-egui/src/canvas/selection_cache_tests.rs @@ -13,6 +13,7 @@ use serde_json::{Value, json}; use super::overlays::{SelectionGeometry, overlays, selection_geometry}; use crate::theme::{Tokens, set_canvas_page}; use crate::{FigApp, NoServices}; +use figcraft_engine::tools::{PointerEvent, PointerKind}; fn id(v: Value) -> NodeId { NodeId(v["id"].as_u64().unwrap()) @@ -176,3 +177,63 @@ fn the_position_fields_follow_edits_and_the_selection() { app.set_props(&[ids[1]], json!({"x": 90})); assert_eq!(x(&app), Some(90.0), "an edit"); } + +/// The visual box of every shape one frame of the canvas overlays draws, in drawing order. +fn boxes(app: &FigApp, ctx: &Context) -> Vec { + let mut out = ctx.run_ui(Default::default(), |ui| { + overlays(app, &ui.ctx().layer_painter(LayerId::background()), &Tokens::light()); + }); + out.textures_delta.clear(); + out.shapes.iter().map(|s| s.shape.visual_bounding_rect()).collect() +} + +/// A drag of the selection held at (`x`, `y`) on the page, from (10, 10); snapping is off (Cmd). +fn drag_to(app: &mut FigApp, x: f64, y: f64) { + let ev = |kind, x, y| PointerEvent { cmd: true, ..PointerEvent::at(kind, x, y, 1.0) }; + if app.session.ds().unwrap().interaction.is_none() { + app.session.pointer(&ev(PointerKind::Down, 10.0, 10.0)).unwrap(); + } + app.session.pointer(&ev(PointerKind::Move, x, y)).unwrap(); +} + +#[test] +fn a_move_shifts_the_geometry_kept_from_where_it_started() { + let (mut app, ctx, _) = setup(); + let start = app.session.ds().unwrap().doc.clone(); + drag_to(&mut app, 47.0, 71.0); + assert_eq!(app.session.ds().unwrap().interaction.as_ref().and_then(|i| i.moved_by), Some((37.0, 61.0))); + let moving = boxes(&app, &ctx); + let first = selection_geometry(&app, &Painter::new(ctx.clone(), LayerId::background(), Rect::EVERYTHING), &start, &app.session.selection()); + drag_to(&mut app, 57.0, 91.0); + let again = boxes(&app, &ctx); + let kept_since = selection_geometry(&app, &Painter::new(ctx.clone(), LayerId::background(), Rect::EVERYTHING), &start, &app.session.selection()); + assert!(kept(&first, &kept_since), "the geometry where the move started is kept for it"); + // What shows is what the moved document shows when nothing is kept, to a hundredth of a point. + let mut fresh = FigApp::new(Session::new(), Box::new(NoServices)); + fresh.canvas.rect = app.canvas.rect; + { + let (ds, now) = (fresh.session.ds_mut().unwrap(), app.session.ds().unwrap()); + (ds.doc, ds.selection, ds.view) = (now.doc.clone(), now.selection.clone(), now.view.clone()); + } + let ctx2 = Context::default(); + set_canvas_page(&ctx2, Color32::WHITE); + let want = boxes(&fresh, &ctx2); + assert_eq!(again.len(), want.len()); + for (got, want) in again.iter().zip(&want) { + assert!((got.min - want.min).length() < 0.01 && (got.max - want.max).length() < 0.01, "{got:?} != {want:?}"); + } + assert_ne!(moving, again, "the second move shows further on"); +} + +#[test] +fn the_gesture_start_stands_in_only_while_the_selection_is_only_moved() { + let (mut app, _, _) = setup(); + let start = app.session.ds().unwrap().doc.clone(); + assert!(crate::selection_cache::moved(app.session.ds().unwrap()).is_none(), "no gesture"); + drag_to(&mut app, 30.0, 10.0); + let (base, d) = crate::selection_cache::moved(app.session.ds().unwrap()).expect("a plain move"); + assert!(Arc::ptr_eq(&base, &start) && d == (20.0, 0.0)); + // The selection changing under the gesture: the start no longer stands in. + app.session.ds_mut().unwrap().selection.pop(); + assert!(crate::selection_cache::moved(app.session.ds().unwrap()).is_none()); +} diff --git a/crates/ui-egui/src/canvas/selection_spacing.rs b/crates/ui-egui/src/canvas/selection_spacing.rs index 8dc9db62b..04a4ecb92 100644 --- a/crates/ui-egui/src/canvas/selection_spacing.rs +++ b/crates/ui-egui/src/canvas/selection_spacing.rs @@ -28,13 +28,16 @@ struct LayoutCache { /// refreshing next frame after an edit and reusing an unchanged document snapshot. fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc { let Ok(ds) = app.session.ds() else { return Arc::new(SelectionSpacing::default()) }; + // The gaps between layers moved together stay as they were: while a gesture only moves the + // selection, they come from where it started. + let doc = crate::selection_cache::moved(ds).filter(|_| ds.selection == ids).map_or_else(|| ds.doc.clone(), |(b, _)| b); let key = Id::unique("selection_spacing_layout"); let frame = ctx.cumulative_frame_nr(); if let Some(mut cache) = ctx.data(|d| d.get_temp::(key)) && cache.document == ds.uid && cache.page == ds.page && cache.ids == ids - && (cache.frame == frame || Arc::ptr_eq(&cache.doc, &ds.doc)) + && (cache.frame == frame || Arc::ptr_eq(&cache.doc, &doc)) { if cache.frame != frame { cache.frame = frame; @@ -44,13 +47,10 @@ fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc, + mixed: bool, + /// Every selected layer is a text layer. + text: bool, + /// The layers an export writes, the first one's preview, and the button's label. + layers: BTreeSet, + first: Option, + name: Option, +} + +fn exports(doc: &Document, sel: &[NodeId]) -> Exports { + let shared = common(doc, sel, |n| n.exports.clone()); + let jobs = export_jobs(doc, sel); + let layers: BTreeSet = jobs.iter().map(|j| j.id).collect(); + Exports { + mixed: shared.is_none() && sel.len() > 1, + list: shared.unwrap_or_default(), + text: sel.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))), + first: jobs.first().map(|j| j.id), + name: layers.first().and_then(|i| doc.node(*i)).map(|n| n.name.clone()), + layers, + } +} + +pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, sel: &[NodeId]) { let t = Tokens::get(ui.ctx()); let ids = sel.to_vec(); - let shared = common(doc, sel, |n| n.exports.clone()); - let mixed = shared.is_none() && sel.len() > 1; - let list = shared.unwrap_or_default(); + let Exports { list, mixed, text, layers, first, name } = crate::selection_cache::cached(ui.ctx(), "exports", doc, sel, &[], || exports(doc, sel)); let tip = if mixed { tr("Replace mixed export settings") } else { tr("Add export setting") }; if widgets::section_header(ui, tr("Export"), &[(Icon::Plus, tip)], list.is_empty()).is_some() { if mixed { @@ -40,20 +68,13 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { if mixed { widgets::note(ui, tr("Click + to replace mixed content"), t.text_secondary); } - let text = sel.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))); for (i, e) in list.iter().enumerate() { setting_row(app, ui, &ids, i, e, text); } - let jobs = export_jobs(doc, sel); - if jobs.is_empty() { - return; - } - if let Some(first) = jobs.first().map(|j| j.id) { - preview(app, ui, doc, first); - } - let layers: BTreeSet = jobs.iter().map(|j| j.id).collect(); - let label = match (layers.len(), layers.first().and_then(|i| doc.node(*i))) { - (1, Some(n)) => trf("Export {name}", &[("name", &n.name)]), + let Some(first) = first else { return }; + preview(app, ui, doc, first); + let label = match (layers.len(), name) { + (1, Some(name)) => trf("Export {name}", &[("name", &name)]), (k, _) => trn("Export {n} layers", k, &[]), }; if wide_button(ui, &label) { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 152aae707..58a1a6e19 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -248,58 +248,61 @@ fn design(app: &mut FigApp, ui: &mut Ui) { let Ok(ds) = app.session.ds() else { return }; // The panel shows the document itself, not a live text preview over it. let doc = ds.text_preview.clone().unwrap_or_else(|| ds.doc.clone()); + // While a gesture only moves the selection, nothing the sections show changes but where it + // is: they show the document it started from, so what they keep stays kept (#1571). + let shown = crate::selection_cache::moved(ds).filter(|_| ds.text_preview.is_none()).map_or_else(|| doc.clone(), |(b, _)| b); let sel = ds.selection.clone(); let vector_edit = ds.vector_edit.clone(); if sel.is_empty() { page_section(app, ui, &doc); return; } - header(app, ui, &doc, &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 // and dimensions. let points_selected = vector_edit.as_ref().is_some_and(|ve| !ve.vertices.is_empty() || !ve.segments.is_empty()); let vector_edit_none = vector_edit.is_none(); match vector_edit { - Some(ve) => crate::vector_panel::points(app, ui, &doc, &ve), + Some(ve) => crate::vector_panel::points(app, ui, &shown, &ve), None => { - crate::section_panel::section(app, ui, &doc, &sel); + crate::section_panel::section(app, ui, &shown, &sel); if app.session.tool.tool == figcraft_engine::ToolId::Scale { - crate::scale_panel::section(app, ui, &doc, &sel); + crate::scale_panel::section(app, ui, &shown, &sel); widgets::separator(ui); } - position(app, ui, &doc, &sel); + position(app, ui, &doc, &shown, &sel); } } widgets::separator(ui); if !points_selected { - crate::layout_panel::layout_section(app, ui, &doc, &sel); + crate::layout_panel::layout_section(app, ui, &shown, &sel); widgets::separator(ui); } - if vector_edit_none && arc_section(app, ui, &doc, &sel) || points_section(app, ui, &doc, &sel) { + if vector_edit_none && arc_section(app, ui, &shown, &sel) || points_section(app, ui, &shown, &sel) { widgets::separator(ui); } - appearance(app, ui, &doc, &sel); + appearance(app, ui, &shown, &sel); widgets::separator(ui); - if variable_modes(app, ui, &doc, &sel) { + 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(_)))) { - crate::typography::typography(app, ui, &doc, &sel); + crate::typography::typography(app, ui, &shown, &sel); widgets::separator(ui); } - paints(app, ui, &doc, &sel, true); + paints(app, ui, &shown, &sel, true); widgets::separator(ui); - paints(app, ui, &doc, &sel, false); + paints(app, ui, &shown, &sel, false); widgets::separator(ui); - effects(app, ui, &doc, &sel); + effects(app, ui, &shown, &sel); widgets::separator(ui); - crate::selection_colors::section(app, ui, &doc, &sel); + 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())) { - grids(app, ui, &doc, &sel); + grids(app, ui, &shown, &sel); widgets::separator(ui); } - crate::export_panel::section(app, ui, &doc, &sel); + crate::export_panel::section(app, ui, &shown, &sel); } /// Draw the page section: background and colour profile, local variables/styles, and the @@ -519,10 +522,12 @@ pub(crate) fn in_instance(ctx: &egui::Context, doc: &Arc, sel: &[NodeI }) } -fn position(app: &mut FigApp, ui: &mut Ui, doc: &Arc, sel: &[NodeId]) { +/// The Position section: its values from `doc`, what doesn't change with where the layers are from +/// `shown` (the panel's document, see `design`). +fn position(app: &mut FigApp, ui: &mut Ui, doc: &Arc, shown: &Arc, sel: &[NodeId]) { widgets::section_header(ui, tr("Position"), &[], false); // Layers inside an instance take their place and constraints from the main component. - let nested = in_instance(ui.ctx(), doc, sel).nested; + let nested = in_instance(ui.ctx(), shown, sel).nested; ui.add_enabled_ui(!nested, |ui| position_fields(app, ui, doc, sel)) .response .on_disabled_hover_text(tr("Layers in an instance take their position from the main component")); diff --git a/crates/ui-egui/src/selection_cache.rs b/crates/ui-egui/src/selection_cache.rs index 63ede6abb..e799c48b0 100644 --- a/crates/ui-egui/src/selection_cache.rs +++ b/crates/ui-egui/src/selection_cache.rs @@ -42,6 +42,16 @@ pub(crate) fn cached( value } +/// While a gesture has only moved the selection (`Interaction::moved_by`): the document it +/// started from and how far it moved them (page units). What depends only on the selected layers' +/// shapes, not where they are, is worked out from that document, so it is kept for the whole +/// gesture instead of made again on every move (#1571). +pub(crate) fn moved(ds: &figcraft_engine::DocState) -> Option<(Arc, (f64, f64))> { + let it = ds.interaction.as_ref()?; + let d = it.moved_by?; + (it.selection == ds.selection).then(|| (it.doc.clone(), d)) +} + /// 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 dcfeb15f4..4ecdc4299 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 (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 (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 @@ -76,6 +76,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1571 (part 3): the first frame after each move of a drag rebuilt the selection's outlines and box, its slots, the selection spacing and the Design panel's values from the new document. Now the engine notes when a drag has only moved the selected layers, each by the same offset from where it started (`Interaction::moved_by`: not for a layer in an auto layout flow, nor an Alt copy), and the canvas and panel work those out once from the document the drag started from and shift what depends on position (the outlines on screen, the box); the Design panel shows that document's values, all but the position fields, which follow the move. The Export section's values are kept too. Nothing looks different. Dragging 648 of about 1,900 synthetic layers (release build, two runs each): the UI pass 2.10 to 1.44 ms a frame, CPU 135% to 117% of a core; the Design panel 0.9 to 0.5 ms, the overlays 0.5 to 0.3 ms. Not yet: the engine's preview of each move (copying and normalising the document) and the canvas texture's whole upload. Tests: `moved_by_tests.rs`, `canvas/selection_cache_tests.rs`. | | 2026-10-30 | minor | M12, #1597: Shift+Cmd/Ctrl+Enter on a board shape adds the next shape already connected (stickies unchanged). Tests: `board_connected_tests`, `board_ui_tests` | | 2026-10-30 | minor | M12, #1597: boards create the next shape already connected. A selected sticky or shape shows a "+" beside each of its four sides (clear of the contextual bar, the size label and the toolbar); hovering one shows where the next goes and the connector to it; clicking makes a sticky, or a shape of the same kind, size and colour, 80 units away on that side, joined to it by a connector the way the Connector tool makes one, and starts typing in it. One undo step takes both away. `board.addConnected {id?, side?, edit?}` does it from commands, MCP and plugins. Shift+Cmd/Ctrl+Enter still adds a sticky without a connector. Tests: `board_connected_tests.rs` (engine), `board_connect_tests.rs` (the handles, headless). | | 2026-10-30 | minor | M4, #1292: a `.fig` import said a family was not installed and drew it in a fallback font although the font picker listed it. Fonts name a family two ways: a typographic name ("Economia", style "Medium") and a legacy name per weight ("Economia Medium", style "Regular"); fonts without typographic names are installed under the legacy ones only, while a file may use either. The font database now resolves a family + style either way (`FontDb::resolve`, used by every lookup: import, paste, REST import, rendering, the missing-fonts check and the font report): a family named with weight words finds the family without them, a style the family lacks finds the family named with it, and the Style list offers those weights. Family names also match across case, spaces, hyphens and underscores, and a weight number style ("600") goes by that weight. `.fig` and REST imports wait for the installed fonts to be indexed before their text is laid out. Widths aren't taken for weights (a missing "Roboto Condensed" stays missing). Tests: `fontdb_names_tests.rs` (synthetic names), `fig_font_names_tests.rs` (a `.fig` round trip). |