From 5247f73f59be04e9bbef488f0f3b08f9ed8e170c Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 11:54:44 +0700 Subject: [PATCH] M8: a large selection's overlay and Design panel values are kept for frames where nothing changed (#1611) * M8: a large selection's overlay and Design panel values are kept for frames where nothing changed (#1571) The selection's outlines, box, size and slots on the canvas, and the Design panel's instance checks and position values, are kept with the document snapshot (held weakly), the selection and, for the overlay, the view's mapping to the screen and the overlay green, and made again when any changes. Dragging 648 selected layers: overlays 1.99 to 0.86 ms a frame, Design panel 2.54 to 1.40 ms. * M8: split the position fields into one function per row (Sonar S3776) --- ROADMAP.md | 3 +- crates/ui-egui/src/canvas.rs | 3 + crates/ui-egui/src/canvas/outline.rs | 59 +++--- crates/ui-egui/src/canvas/overlays.rs | 56 ++++-- .../src/canvas/selection_cache_tests.rs | 178 ++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/layout_panel.rs | 8 +- crates/ui-egui/src/panels/right.rs | 190 ++++++++++++------ crates/ui-egui/src/selection_cache.rs | 48 +++++ docs/roadmap.md | 3 +- 10 files changed, 441 insertions(+), 108 deletions(-) create mode 100644 crates/ui-egui/src/canvas/selection_cache_tests.rs create mode 100644 crates/ui-egui/src/selection_cache.rs diff --git a/ROADMAP.md b/ROADMAP.md index 506cff93..98833f29 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 (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 (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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1571 (part): a large selection redid the same work on every frame. A pointer move draws two to five egui frames, and each one worked out every selected layer's outline, the selection box and size, the slots shown, and the Design panel's instance checks and position values (X, Y, rotation, constraints, alignment) again, though only the first frame after a move or an edit can differ. Now they are kept with the document snapshot, the selection and, for the overlay, the view's mapping to the screen and the overlay green, and made again when any of those changes (`selection_cache`); the hover and drop-target highlights are drawn every frame as before. Nothing looks different. Dragging 648 layers of a synthetic file of about 1,900 (release build, control-driven, two frames per move): the canvas overlays 1.99 to 0.86 ms a frame, the Design panel 2.54 to 1.40 ms. Not yet: the frame of each move still works everything out (the selection spacing, the export section, the outlines), the canvas texture is uploaded whole after each render. Tests: `canvas/selection_cache_tests.rs`. | | 2026-10-30 | minor | M8, #1089: libraries and plugins larger than one address's download burst (16 MiB) could never be downloaded: the per-address token bucket never held more than its burst, so a private server's 40 or 64 MiB team library was refused for good. Now a download larger than the burst is served once the address's budget is full, leaving it in debt that refills at 1 MiB a second before its next download (`Bucket::take_spread`, also for everyone's library budget); within the burst nothing changes. Over any stretch of time an address still gets at most the largest object plus the rate times that time. `server.md` (rates, threat model). Tests: `download_burst_tests.rs` (the bucket's loan and payback, ten minutes of 40 MiB requests kept to the rate, a 40 MiB library served and then the address waits). | | 2026-10-30 | minor | M8, #1099: `command.batch` (and every grouped run: plugins, scripts, MCP `batch`) kept a full undo snapshot per step until the batch ended, so a long batch held one document copy per step. Inside a group, edits now add to the one undo step the group pushed, so a batch holds one snapshot however long it is; it is still one undo step that restores exactly the state before it, and a failure still rolls everything back. An `edit.undo` step inside a batch now undoes the batch's edits so far together. Measured on a 2,000-layer file with 160 KB of document colours (debug build): a 1,000-step batch went from +165 MiB peak and 80 to 100 ms to +10 MiB and about 50 ms (4,000 steps: +35 MiB, the step list and command journal). Numbers unchanged. Tests: `batch_undo_snapshot_tests`. | | 2026-10-30 | minor | M8, #1101: the importers' 256-level depth cap could overflow the browser's 1 MB stack before it was reached. Measured per level: the REST import's layer walk (also the second stage of every `.fig` import) took about 4 KB, a whole megabyte at 256, and overflowed a 1 MB thread; it now runs on a work list in the same order (groups fitted once their children are in), a few KB in all. The `.fig` mapping, which builds each layer from its children's results, stays recursive at about 1.8 KB a level, 455 KB at the cap. Desktop and browser keep the 256-level cap, so nothing that loads today is cut off. Numbers unchanged. Tests: `deep_import_tests` (256 and 400 levels on a 1 MB thread). | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0fe1c315..ae39c7e0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -374,6 +374,9 @@ mod builder_poly_tests; #[cfg(test)] mod outline_tests; +#[cfg(test)] +mod selection_cache_tests; + #[cfg(test)] mod wheel_zoom_tests; diff --git a/crates/ui-egui/src/canvas/outline.rs b/crates/ui-egui/src/canvas/outline.rs index a0dc2945..154a4de0 100644 --- a/crates/ui-egui/src/canvas/outline.rs +++ b/crates/ui-egui/src/canvas/outline.rs @@ -71,7 +71,30 @@ pub(super) fn quad(painter: &egui::Painter, q: [Pos2; 4], s: Stroke) { /// The slots of the selected components and instances (and a slot being dropped onto): a dashed /// outline and the slot's name above its top-left corner, like a frame label (never over the /// content). -pub(super) fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Document, sel: &[NodeId], drop: Option) { +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)); + for (id, name) in slots { + let Some(q) = node_quad(app, doc, id) else { continue }; + if !Rect::from_points(&q).intersects(app.canvas.rect) { + continue; + } + painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0)); + // The label, when the slot is wide enough on screen to hold it. + let galley = painter.layout_no_wrap(name, regular(10.0), COMPONENT); + let (w, h) = (galley.size().x + 14.0, galley.size().y.max(11.0)); + if (q[1] - q[0]).length() < w { + continue; + } + let at = q[0] - vec2(0.0, h + 2.0); + crate::icons::paint(painter, Rect::from_min_size(pos2(at.x, at.y + (h - 11.0) / 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Slot, COMPONENT); + painter.galley(pos2(at.x + 14.0, at.y + (h - galley.size().y) / 2.0), galley, COMPONENT); + } +} + +/// The slots [`slot_badges`] shows, with their names: those of the selected components and +/// instances (and of the ones the selected layers are in), and the slot being dropped onto. +fn slots_shown(doc: &Document, sel: &[NodeId], drop: Option) -> Vec<(NodeId, String)> { use figcraft_engine::components::{owning_component, owning_instance, slot_name, slots_of}; let mut roots: Vec = Vec::new(); for id in sel { @@ -90,22 +113,7 @@ pub(super) fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Document, { slots.push((d, name)); } - for (id, name) in slots { - let Some(q) = node_quad(app, doc, id) else { continue }; - if !Rect::from_points(&q).intersects(app.canvas.rect) { - continue; - } - painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0)); - // The label, when the slot is wide enough on screen to hold it. - let galley = painter.layout_no_wrap(name, regular(10.0), COMPONENT); - let (w, h) = (galley.size().x + 14.0, galley.size().y.max(11.0)); - if (q[1] - q[0]).length() < w { - continue; - } - let at = q[0] - vec2(0.0, h + 2.0); - crate::icons::paint(painter, Rect::from_min_size(pos2(at.x, at.y + (h - 11.0) / 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Slot, COMPONENT); - painter.galley(pos2(at.x + 14.0, at.y + (h - galley.size().y) / 2.0), galley, COMPONENT); - } + slots } /// The instance an Alt-drop of a component from the Assets panel swaps: tinted and outlined in @@ -197,16 +205,13 @@ fn outline_shape(app: &FigApp, doc: &Document, id: NodeId) -> Option Vec { match outline_shape(app, doc, id) { - Some(lines) => { - for l in lines { - painter.add(Shape::line(l, s)); - } - } - None => { - if let Some(q) = node_quad(app, doc, id) { - quad(painter, q, s); - } - } + Some(lines) => lines.into_iter().map(|l| Shape::line(l, s)).collect(), + None => node_quad(app, doc, id).map(|q| Shape::closed_line(q.to_vec(), s)).into_iter().collect(), } } diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 5e932324..e34dc341 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -1,5 +1,7 @@ +use std::sync::Arc; + use super::builder::{builder_overlay, hatched, region_hatch}; -use super::outline::{TextOnScreen, draw_outline, node_quad, outline_colour, quad, slot_badges, stroke, swap_highlight}; +use super::outline::{TextOnScreen, draw_outline, node_quad, outline_colour, outline_shapes, quad, slot_badges, stroke, swap_highlight}; use super::rulers::{boards, fmt, rulers}; use super::{ Align2, COMPONENT, Color32, CornerRadius, Document, FigApp, NodeId, NodeKind, Point, Pos2, Rect, Shape, Stroke, StrokeKind, Tokens, ToolId, @@ -234,20 +236,51 @@ fn text_edit_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Do painter.ctx().request_repaint_after(std::time::Duration::from_millis(500)); } +/// What the selection's overlay draws that only the document, the selection, the view and the +/// overlay colour decide: kept for the frames that show the same ([`crate::selection_cache`]). +#[derive(Clone, Debug, PartialEq)] +pub(super) struct SelectionGeometry { + /// The outline colour: the component colour when any selected layer is in one. + pub(super) col: Color32, + /// Every selected layer's outline on screen. + pub(super) outlines: Arc>, + /// The transform box (page space) and its width and height. + bbox: Option<[Point; 4]>, + size: Option<(f64, f64)>, + /// A selected layer is locked (no handles). + locked: bool, +} + +pub(super) fn selection_geometry(app: &FigApp, painter: &egui::Painter, doc: &Arc, sel: &[NodeId]) -> SelectionGeometry { + let green = accent(painter); + let mapping = [app.to_screen(0.0, 0.0), app.to_screen(1.0, 1.0)].map(crate::selection_cache::bits); + let extra = [mapping[0][0], mapping[0][1], mapping[1][0], mapping[1][1], u64::from(u32::from_le_bytes(green.to_array()))]; + crate::selection_cache::cached(painter.ctx(), "selection overlay", doc, sel, &extra, || { + let col = if sel.iter().any(|i| outline_colour(doc, *i, green) == COMPONENT) { COMPONENT } else { green }; + let outlines = sel.iter().flat_map(|id| outline_shapes(app, doc, *id, stroke(col, 1.0))).collect(); + SelectionGeometry { + col, + outlines: Arc::new(outlines), + bbox: selection_box(doc, sel), + size: selection_frame(doc, sel).map(|(_, w, h)| (w, h)), + locked: sel.iter().any(|i| figcraft_engine::tools::locked_in(doc, *i)), + } + }) +} + /// The selection's outlines, transform box and handles; returns the size label's box. fn selection_overlay( app: &FigApp, painter: &egui::Painter, ds: &figcraft_engine::DocState, - doc: &Document, + doc: &Arc, sel: &[NodeId], zoom: f64, ) -> Option { let mut size_label = None; - let col = if sel.iter().any(|i| outline_colour(doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) }; - for id in sel { - draw_outline(app, painter, doc, *id, stroke(col, 1.0)); - } + let geo = selection_geometry(app, painter, doc, sel); + let col = geo.col; + painter.extend(geo.outlines.iter().cloned()); // 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() { @@ -255,8 +288,8 @@ fn selection_overlay( } // The shape builder reshapes by regions: no transform box. if app.session.tool.tool != ToolId::ShapeBuilder - && let Some(b) = selection_box(doc, sel) - && let Some((_, w, h)) = selection_frame(doc, sel) + && let Some(b) = geo.bbox + && 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)); // A selected connector's handles are its two ends (drag one onto another object). @@ -275,12 +308,7 @@ fn selection_overlay( // Mode: no handles either. // Crop mode draws its own marks on the box instead (`handles::crop_marks`). let cropping = app.ui.crop.is_some(); - let handles = if cropping - || measuring - || app.session.tool.dev_mode - || connector.is_some() - || sel.iter().any(|i| figcraft_engine::tools::locked_in(doc, *i)) - { + let handles = if cropping || measuring || app.session.tool.dev_mode || connector.is_some() || geo.locked { 0 } else if is_line { 2 diff --git a/crates/ui-egui/src/canvas/selection_cache_tests.rs b/crates/ui-egui/src/canvas/selection_cache_tests.rs new file mode 100644 index 00000000..ca20e544 --- /dev/null +++ b/crates/ui-egui/src/canvas/selection_cache_tests.rs @@ -0,0 +1,178 @@ +//! The selection overlay's geometry and the Design panel's values are kept for the frames that +//! show the same (#1571), made again on every input they show (an edit, the selection, zoom, pan, +//! the canvas's place, the theme's overlay green), and the same as when made afresh; what they +//! don't keep (the hover and drop-target highlight) shows on the next frame. + +use std::sync::Arc; + +use egui::{Color32, Context, LayerId, Painter, Rect, pos2, vec2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use super::overlays::{SelectionGeometry, overlays, selection_geometry}; +use crate::theme::{Tokens, set_canvas_page}; +use crate::{FigApp, NoServices}; + +fn id(v: Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// Three rectangles (the middle one rounded, outlined along its corners) selected, and a fourth +/// that isn't, on a light page. +fn setup() -> (FigApp, Context, Vec) { + let mut s = Session::new(); + let mut ids = Vec::new(); + for x in [0, 80, 160, 240] { + ids.push(id(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 50, "height": 50})).unwrap())); + } + let radii = json!({"topLeftRadius": 12, "topRightRadius": 12, "bottomRightRadius": 12, "bottomLeftRadius": 12}); + s.execute("node.set", &json!({"id": ids[1], "props": radii})).unwrap(); + s.execute("select.set", &json!({"ids": ids[..3]})).unwrap(); + 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, ids) +} + +fn geometry(app: &FigApp, ctx: &Context) -> SelectionGeometry { + let ds = app.session.ds().unwrap(); + selection_geometry(app, &Painter::new(ctx.clone(), LayerId::background(), Rect::EVERYTHING), &ds.doc.clone(), &ds.selection.clone()) +} + +/// The geometry made with nothing kept, on a page of colour `page`. +fn fresh(app: &FigApp, page: Color32) -> SelectionGeometry { + let ctx = Context::default(); + set_canvas_page(&ctx, page); + geometry(app, &ctx) +} + +fn kept(a: &SelectionGeometry, b: &SelectionGeometry) -> bool { + Arc::ptr_eq(&a.outlines, &b.outlines) +} + +#[test] +fn an_unchanged_frame_reuses_the_selection_geometry() { + let (app, ctx, _) = setup(); + let first = geometry(&app, &ctx); + assert_eq!(first.outlines.len(), 3); + assert!(kept(&first, &geometry(&app, &ctx))); + assert_eq!(first, fresh(&app, Color32::WHITE)); +} + +#[test] +fn a_kept_value_does_not_keep_its_document_alive() { + let (app, ctx, _) = setup(); + let doc = app.session.ds().unwrap().doc.clone(); + let holders = Arc::strong_count(&doc); + geometry(&app, &ctx); + assert_eq!(Arc::strong_count(&doc), holders); +} + +type Change = Box; + +#[test] +fn every_input_the_geometry_shows_makes_it_again() { + let (mut app, ctx, ids) = setup(); + let (a, b) = (ids[0], ids[1]); + let changes: Vec<(&str, Change)> = vec![ + ( + "an edit", + Box::new(move |app: &mut FigApp, _: &Context| { + app.set_props(&[b], json!({"x": 90})); + }), + ), + ( + "the selection", + Box::new(move |app: &mut FigApp, _: &Context| { + app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + }), + ), + ("zoom", Box::new(|app: &mut FigApp, _: &Context| app.session.ds_mut().unwrap().view.zoom *= 2.0)), + ("pan", Box::new(|app: &mut FigApp, _: &Context| app.session.ds_mut().unwrap().view.cx += 30.0)), + ("the canvas moving", Box::new(|app: &mut FigApp, _: &Context| app.canvas.rect = app.canvas.rect.translate(vec2(40.0, 0.0)))), + ("the theme", Box::new(|_: &mut FigApp, ctx: &Context| set_canvas_page(ctx, Color32::BLACK))), + ]; + let mut page = Color32::WHITE; + for (what, change) in changes { + let before = geometry(&app, &ctx); + change(&mut app, &ctx); + if what == "the theme" { + page = Color32::BLACK; + assert_ne!(crate::theme::canvas_accent(&ctx), before.col, "a dark page takes the other green"); + } + let after = geometry(&app, &ctx); + assert!(!kept(&before, &after), "{what}"); + assert_ne!(before, after, "{what}"); + assert_eq!(after, fresh(&app, page), "{what}: as if nothing were kept"); + assert!(kept(&after, &geometry(&app, &ctx)), "{what}: kept again"); + } +} + +/// How many shapes one frame of the canvas overlays draws. +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()); + }); + out.textures_delta.clear(); + out.shapes.len() +} + +#[test] +fn the_hover_and_drop_highlights_show_over_kept_geometry() { + let (mut app, ctx, ids) = setup(); + let other = ids[3]; + let plain = drawn(&app, &ctx); + let first = geometry(&app, &ctx); + app.session.ds_mut().unwrap().hover = Some(other); + assert_eq!(drawn(&app, &ctx), plain + 1, "the hovered layer's outline"); + assert!(kept(&first, &geometry(&app, &ctx)), "the hover isn't part of the selection's geometry"); + app.session.ds_mut().unwrap().hover = None; + app.session.tool.overlay.drop_target = Some(other); + assert_eq!(drawn(&app, &ctx), plain + 1, "the drop target's outline"); + app.session.tool.overlay.drop_target = None; + assert_eq!(drawn(&app, &ctx), plain); +} + +#[test] +fn the_design_panels_instance_facts_follow_edits_and_the_selection() { + let mut s = Session::new(); + let f = id(s.execute("node.create", &json!({"type": "frame", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + id(s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 10, "width": 20, "height": 20, "parent": f})).unwrap()); + let main = id(s.execute("component.create", &json!({"ids": [f]})).unwrap()); + let inst = id(s.execute("instance.create", &json!({"component": main, "x": 300, "y": 0})).unwrap()); + let child = s.doc().unwrap().node(inst).unwrap().children[0]; + let ctx = Context::default(); + let facts = |s: &Session| { + let ds = s.ds().unwrap(); + crate::right::in_instance(&ctx, &ds.doc.clone(), &ds.selection.clone()) + }; + let facts_of = |s: &mut Session, ids: Value| { + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let f = facts(s); + (f.any, f.nested) + }; + assert_eq!(facts_of(&mut s, json!([main])), (false, false)); + assert_eq!(facts_of(&mut s, json!([inst])), (true, false)); + assert_eq!(facts_of(&mut s, json!([inst, child])), (true, true)); + assert_eq!(facts_of(&mut s, json!([inst])), (true, false)); + s.execute("component.detach", &json!({"ids": [inst]})).unwrap(); + assert!(s.doc().unwrap().node(inst).is_some(), "detaching keeps the layer"); + assert_eq!(s.ds().unwrap().selection, vec![inst], "and the selection"); + assert_eq!(facts(&s), crate::right::InInstance { any: false, nested: false }, "an edit"); +} + +#[test] +fn the_position_fields_follow_edits_and_the_selection() { + let (mut app, ctx, ids) = setup(); + let x = |app: &FigApp| { + let ds = app.session.ds().unwrap(); + crate::right::position_values(&ctx, &ds.doc.clone(), &ds.selection.clone()).x + }; + assert_eq!(x(&app), None, "mixed"); + app.session.execute("select.set", &json!({"ids": [ids[1]]})).unwrap(); + assert_eq!(x(&app), Some(80.0), "the selection"); + app.set_props(&[ids[1]], json!({"x": 90})); + assert_eq!(x(&app), Some(90.0), "an edit"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b69840af..2c4b768f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -307,6 +307,7 @@ pub use control::{ControlReply, ControlRequest, RequestGate, RequestState}; mod app; mod clock; mod open; +mod selection_cache; mod services; mod state; diff --git a/crates/ui-egui/src/panels/layout_panel.rs b/crates/ui-egui/src/panels/layout_panel.rs index b21e074c..117715d8 100644 --- a/crates/ui-egui/src/panels/layout_panel.rs +++ b/crates/ui-egui/src/panels/layout_panel.rs @@ -4,6 +4,8 @@ //! alignment in its cell, and clip content. Everything goes through `node.set` props or `layout.*` //! commands. +use std::sync::Arc; + use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_doc::{AutoLayout, CellAlign, ContentAlign, CrossAlign, Document, LayoutMode, MainAlign, Node, NodeId, NodeKind, Sizing}; use serde_json::json; @@ -25,7 +27,7 @@ fn parent_of(doc: &Document, id: NodeId) -> Option<&Node> { /// Show selection sizing and layout controls; offer Add only when every selected container has /// no auto layout and the selection is editable outside an instance. -pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { +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))); @@ -35,8 +37,8 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node let title = if has_al { tr("Auto layout") } else { tr("Layout") }; // An instance's auto layout is its main's (only padding, gap and alignment are its own); the // layers inside it override their size and resizing, not their place in the layout. - let in_instance = sel.iter().any(|i| figcraft_engine::components::owning_instance(doc, *i).is_some()); - let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i)); + let facts = crate::right::in_instance(ui.ctx(), doc, sel); + let (in_instance, nested) = (facts.any, facts.nested); let btns: Vec<(Icon, &str)> = if in_instance { vec![] } else if layout_containers && no_al { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 2eeccea5..152aae70 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -1,5 +1,7 @@ //! The right panel: Design, Prototype and Inspect tabs. +use std::sync::Arc; + use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2}; use figcraft_color::{BlendMode, Paint, PaintKind}; use figcraft_doc::{ @@ -274,7 +276,7 @@ fn design(app: &mut FigApp, ui: &mut Ui) { crate::layout_panel::layout_section(app, ui, &doc, &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, &doc, &sel) || points_section(app, ui, &doc, &sel) { widgets::separator(ui); } appearance(app, ui, &doc, &sel); @@ -500,21 +502,95 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { crate::component_panel::instance_section(app, ui, doc, sel); } -fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { +/// Whether a selected layer is an instance or in one, and whether one is inside an instance (not +/// the instance itself): the Design panel asks every frame, kept while the document and the +/// selection stay the same ([`crate::selection_cache`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct InInstance { + pub any: bool, + pub nested: bool, +} + +pub(crate) fn in_instance(ctx: &egui::Context, doc: &Arc, sel: &[NodeId]) -> InInstance { + use figcraft_engine::components::{inside_instance, owning_instance}; + crate::selection_cache::cached(ctx, "in instance", doc, sel, &[], || InInstance { + any: sel.iter().any(|i| owning_instance(doc, *i).is_some()), + nested: sel.iter().any(|i| inside_instance(doc, *i)), + }) +} + +fn position(app: &mut FigApp, ui: &mut Ui, doc: &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 = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i)); + let nested = in_instance(ui.ctx(), doc, 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")); ui.add_space(8.0); } +/// What the position fields show of the selection (`None`: mixed), kept like [`in_instance`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct PositionValues { + pub can_align: bool, + pub x: Option, + pub y: Option, + pub rot: Option, + /// Every selected layer is in an auto layout frame, and whether they ignore its layout. + pub parent_al: bool, + pub absolute: Option, + /// Every selected layer is in a frame that places it by constraints, and those constraints. + pub in_frame: bool, + pub constraints: Option, +} + +/// What the X field shows of a layer (in its frame's space: groups have none of their own); a +/// relative edit of mixed values starts from it. +fn x_of(doc: &Document, n: &Node) -> f64 { + (doc.frame_relative(n.id).as_coeffs()[4] * 100.0).round() / 100.0 +} + +fn y_of(doc: &Document, n: &Node) -> f64 { + (doc.frame_relative(n.id).as_coeffs()[5] * 100.0).round() / 100.0 +} + +fn rot_of(n: &Node) -> f64 { + (figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0 +} + +pub(crate) fn position_values(ctx: &egui::Context, doc: &Arc, sel: &[NodeId]) -> PositionValues { + let parent = |n: &Node| n.parent.and_then(|p| doc.node(p)); + crate::selection_cache::cached(ctx, "position", doc, sel, &[], || PositionValues { + can_align: figcraft_engine::cmd::arrange::can_align(doc, sel), + x: common(doc, sel, |n| x_of(doc, n)), + y: common(doc, sel, |n| y_of(doc, n)), + rot: common(doc, sel, rot_of), + parent_al: sel.iter().all(|i| doc.node(*i).and_then(parent).is_some_and(|p| p.layout.is_some())), + absolute: common(doc, sel, |n| n.child_layout.absolute), + // Constraints: children of frames without auto layout, or ignoring it. + in_frame: sel.iter().all(|i| { + doc.node(*i).is_some_and(|n| parent(n).is_some_and(|p| p.kind.is_frame_like() && (p.layout.is_none() || n.child_layout.absolute))) + }), + constraints: common(doc, sel, |n| n.constraints), + }) +} + /// Draw alignment and distribution, X/Y and rotation with flip/rotate controls, the /// absolute-position toggle and frame constraints. -fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { - let ids = sel.to_vec(); +fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Arc, sel: &[NodeId]) { + let pv = position_values(ui.ctx(), doc, sel); let col = widgets::col_width(ui.available_width()); + align_row(app, ui, sel, pv.can_align, col); + position_row(app, ui, doc, sel, &pv, col); + rotation_row(app, ui, sel, pv.rot, col); + if pv.in_frame { + constraints_row(app, ui, sel, pv.constraints, col); + } +} + +/// Alignment, and distribution and tidy up for three or more layers. +fn align_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], can_align: bool, col: f32) { + let ids = sel.to_vec(); widgets::sublabels(ui, tr("Alignment"), None); row(ui, 32.0, |ui| { let cmds = ["align.left", "align.hcenter", "align.right", "align.top", "align.vcenter", "align.bottom"]; @@ -528,7 +604,7 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] (Icon::AlignBottom, tip(tr("Align bottom"), cmds[5])), ]; let w = col / 3.0; - let picked = ui.add_enabled_ui(figcraft_engine::cmd::arrange::can_align(doc, sel), |ui| { + let picked = ui.add_enabled_ui(can_align, |ui| { let h = widgets::segmented(ui, &items[..3], None, w); let v = widgets::segmented(ui, &items[3..], None, w).map(|i| i + 3); h.or(v) @@ -553,23 +629,20 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] }); } }); - // In the frame's space: groups have none of their own. - // What each field shows of a layer (a relative edit of mixed values starts from it). - let x_of = |n: &Node| (doc.frame_relative(n.id).as_coeffs()[4] * 100.0).round() / 100.0; - let y_of = |n: &Node| (doc.frame_relative(n.id).as_coeffs()[5] * 100.0).round() / 100.0; - let rot_of = |n: &Node| (figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0; - let (x, y, rot) = (common(doc, sel, x_of), common(doc, sel, y_of), common(doc, sel, rot_of)); - let parent_al = sel.iter().all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); - let absolute = common(doc, sel, |n| n.child_layout.absolute); +} + +/// X and Y, and the absolute-position toggle for children of auto layout frames. +fn position_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], pv: &PositionValues, col: f32) { + let ids = sel.to_vec(); widgets::sublabels(ui, tr("Position"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field_mixed(ui, Id::unique("pos_x"), Lead::Text("X"), x, "", col, 1.0); - app.field_of(&ids, "x", e, x_of, |v| json!(v)); - let e = widgets::number_field_mixed(ui, Id::unique("pos_y"), Lead::Text("Y"), y, "", col, 1.0); - app.field_of(&ids, "y", e, y_of, |v| json!(v)); + let e = widgets::number_field_mixed(ui, Id::unique("pos_x"), Lead::Text("X"), pv.x, "", col, 1.0); + app.field_of(&ids, "x", e, |n| x_of(doc, n), |v| json!(v)); + let e = widgets::number_field_mixed(ui, Id::unique("pos_y"), Lead::Text("Y"), pv.y, "", col, 1.0); + app.field_of(&ids, "y", e, |n| y_of(doc, n), |v| json!(v)); // Children of auto layout frames can ignore the layout (absolute position). - if parent_al { - let on = absolute.unwrap_or(false); + if pv.parent_al { + let on = pv.absolute.unwrap_or(false); if icons::button( ui, Icon::AbsolutePosition, @@ -583,6 +656,11 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] } } }); +} + +/// Rotation, with the rotate and flip buttons. +fn rotation_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], rot: Option, col: f32) { + let ids = sel.to_vec(); widgets::sublabels(ui, tr("Rotation"), None); row(ui, 32.0, |ui| { let e = widgets::number_field_mixed(ui, Id::unique("pos_rot"), Lead::Icon(Icon::Angle, tr("Rotation")), rot, "°", col, 1.0); @@ -596,50 +674,38 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] app.run(["object.rotateRight", "object.flipH", "object.flipV"][i], json!({"ids": ids})); } }); - // Constraints (children of frames without auto layout, or ignoring it). - let in_frame = sel.iter().all(|i| { - doc.node(*i).is_some_and(|n| { - n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like() && (p.layout.is_none() || n.child_layout.absolute)) - }) - }); - if in_frame { - let c = common(doc, sel, |n| n.constraints); - widgets::sublabels(ui, tr("Constraints"), None); - row(ui, 32.0, |ui| { - let h = c.map(|c| format!("{:?}", c.horizontal)).unwrap_or_else(|| "Mixed".into()); - let resp = widgets::dropdown(ui, tr(&pretty(&h)), col, None); - menu_popup(&resp).show(|ui| { - for (l, v) in [ - (tr("Left"), "left"), - (tr("Right"), "right"), - (tr("Left & right"), "leftRight"), - (tr("Center"), "center"), - (tr("Scale"), "scale"), - ] { - if menu_item(ui, l, "") { - app.set_props(&ids, json!({"constraints": {"horizontal": v}})); - ui.close(); - } +} + +/// The horizontal and vertical constraints of children of frames without auto layout. +fn constraints_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], c: Option, col: f32) { + let ids = sel.to_vec(); + widgets::sublabels(ui, tr("Constraints"), None); + row(ui, 32.0, |ui| { + let h = c.map(|c| format!("{:?}", c.horizontal)).unwrap_or_else(|| "Mixed".into()); + let resp = widgets::dropdown(ui, tr(&pretty(&h)), col, None); + menu_popup(&resp).show(|ui| { + for (l, v) in + [(tr("Left"), "left"), (tr("Right"), "right"), (tr("Left & right"), "leftRight"), (tr("Center"), "center"), (tr("Scale"), "scale")] + { + if menu_item(ui, l, "") { + app.set_props(&ids, json!({"constraints": {"horizontal": v}})); + ui.close(); } - }); - let v = c.map(|c| format!("{:?}", c.vertical)).unwrap_or_else(|| "Mixed".into()); - let resp = widgets::dropdown(ui, tr(&pretty(&v)), col, None); - menu_popup(&resp).show(|ui| { - for (l, val) in [ - (tr("Top"), "top"), - (tr("Bottom"), "bottom"), - (tr("Top & bottom"), "topBottom"), - (tr("Center"), "center"), - (tr("Scale"), "scale"), - ] { - if menu_item(ui, l, "") { - app.set_props(&ids, json!({"constraints": {"vertical": val}})); - ui.close(); - } - } - }); + } }); - } + let v = c.map(|c| format!("{:?}", c.vertical)).unwrap_or_else(|| "Mixed".into()); + let resp = widgets::dropdown(ui, tr(&pretty(&v)), col, None); + menu_popup(&resp).show(|ui| { + for (l, val) in + [(tr("Top"), "top"), (tr("Bottom"), "bottom"), (tr("Top & bottom"), "topBottom"), (tr("Center"), "center"), (tr("Scale"), "scale")] + { + if menu_item(ui, l, "") { + app.set_props(&ids, json!({"constraints": {"vertical": val}})); + ui.close(); + } + } + }); + }); } /// The selected ellipses' arc: Start (degrees), Sweep (a share of a full turn, negative running diff --git a/crates/ui-egui/src/selection_cache.rs b/crates/ui-egui/src/selection_cache.rs new file mode 100644 index 00000000..63ede6ab --- /dev/null +++ b/crates/ui-egui/src/selection_cache.rs @@ -0,0 +1,48 @@ +//! Values worked out from the document and the selection that several frames show unchanged +//! (#1571): a pointer move draws about three frames, and a large selection's outlines, slots and +//! mixed values cost milliseconds each time. A value is kept with the document snapshot it was +//! made from (compared by pointer: an edit is a new snapshot), the selection and whatever else it +//! depends on (`extra`: the view's mapping to the screen, a colour…), and made again when any +//! differs. + +use std::sync::{Arc, Weak}; + +use figcraft_doc::{Document, 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. +#[derive(Clone)] +struct Kept { + doc: Weak, + sel: Vec, + extra: Vec, + value: T, +} + +/// The value `name` for `doc`, `sel` and `extra`: the kept one when they are what it was made +/// from, else `make()`, kept for next time. +pub(crate) fn cached( + ctx: &egui::Context, + name: &'static str, + doc: &Arc, + sel: &[NodeId], + extra: &[u64], + make: impl FnOnce() -> T, +) -> T { + let id = egui::Id::unique(("selection_cache", name)); + if let Some(k) = ctx.data(|d| d.get_temp::>(id)) + && std::ptr::eq(k.doc.as_ptr(), Arc::as_ptr(doc)) + && k.sel == sel + && k.extra == extra + { + return k.value; + } + let value = make(); + ctx.data_mut(|d| d.insert_temp(id, Kept { doc: Arc::downgrade(doc), sel: sel.to_vec(), extra: extra.to_vec(), value: value.clone() })); + value +} + +/// 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 546e75f7..c2e3d9f0 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 (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 (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 @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1571 (part): a large selection redid the same work on every frame. A pointer move draws two to five egui frames, and each one worked out every selected layer's outline, the selection box and size, the slots shown, and the Design panel's instance checks and position values (X, Y, rotation, constraints, alignment) again, though only the first frame after a move or an edit can differ. Now they are kept with the document snapshot, the selection and, for the overlay, the view's mapping to the screen and the overlay green, and made again when any of those changes (`selection_cache`); the hover and drop-target highlights are drawn every frame as before. Nothing looks different. Dragging 648 layers of a synthetic file of about 1,900 (release build, control-driven, two frames per move): the canvas overlays 1.99 to 0.86 ms a frame, the Design panel 2.54 to 1.40 ms. Not yet: the frame of each move still works everything out (the selection spacing, the export section, the outlines), the canvas texture is uploaded whole after each render. Tests: `canvas/selection_cache_tests.rs`. | | 2026-10-30 | minor | M8, #1089: libraries and plugins larger than one address's download burst (16 MiB) could never be downloaded: the per-address token bucket never held more than its burst, so a private server's 40 or 64 MiB team library was refused for good. Now a download larger than the burst is served once the address's budget is full, leaving it in debt that refills at 1 MiB a second before its next download (`Bucket::take_spread`, also for everyone's library budget); within the burst nothing changes. Over any stretch of time an address still gets at most the largest object plus the rate times that time. `server.md` (rates, threat model). Tests: `download_burst_tests.rs` (the bucket's loan and payback, ten minutes of 40 MiB requests kept to the rate, a 40 MiB library served and then the address waits). | | 2026-10-30 | minor | M8, #1099: a batch's edits add to the one undo step it pushed, so it keeps one snapshot instead of one per step (1,000 steps: +165 MiB to +10 MiB peak); still one undo step, rolled back as a unit. Tests: `batch_undo_snapshot_tests` | | 2026-10-30 | minor | M8, #1101: the REST import's layer walk runs on a work list (it took about 4 KB of stack a level, a megabyte at the 256-level cap); `.fig` and REST files 256 levels deep import on a 1 MB stack, the browser's. Tests: `deep_import_tests` |