From 6b46a9c81a08a8b8c351d63f6884336168f3c12b Mon Sep 17 00:00:00 2001 From: Salah1St <83543012+Salah1St@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:24:53 +0700 Subject: [PATCH 01/83] M7: FIGCRAFT_WEB_FONTS embeds more craft-fonts families in the web build The web build has no system fonts, so it can only draw text in faces compiled into the wasm, and crates/text/build.rs keeps a fixed set: Inter, Noto Sans Georgian, the Archivo wordmark, and Noto Sans CJK with FIGCRAFT_EMBED_CJK. A distribution for users of another script (Thai, Hebrew, Devanagari, ...) had no supported way to add the face they need, so their text was drawn as missing-glyph boxes. FIGCRAFT_WEB_FONTS names extra manifest families, comma-separated, to embed on wasm32. Unset, nothing changes; the variable is declared with rerun-if-env-changed. docs/releasing.md says how to use it. Co-Authored-By: Claude Opus 5.5 --- crates/text/build.rs | 9 +++++++-- docs/releasing.md | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/text/build.rs b/crates/text/build.rs index 05dfc6533..624930002 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -2,6 +2,7 @@ //! opt-in Latin presentation fonts in `fonts/latin-manifest.txt` (Inter, Roboto, …). Unset or missing, //! `CRAFT_FONTS` is empty and the app uses system fonts. Recipe: craft-fonts `docs/integration.md`. //! Noto Sans CJK only with `FIGCRAFT_EMBED_CJK` set (the web build's only way to show CJK UI text). +//! `FIGCRAFT_WEB_FONTS` (comma-separated families) adds manifest families to the web build. use std::fmt::Write as _; use std::path::PathBuf; @@ -9,6 +10,7 @@ fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); println!("cargo::rerun-if-env-changed=FIGCRAFT_EMBED_CJK"); + println!("cargo::rerun-if-env-changed=FIGCRAFT_WEB_FONTS"); let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { let mut ok = false; @@ -54,6 +56,8 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result = line.split(" | ").map(str::trim).collect(); @@ -66,9 +70,10 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result.txt` +Noto Sans Georgian and the Archivo SemiBold wordmark, when the pin has them. A distribution +whose users write another script names the manifest families it needs, comma-separated, in +`FIGCRAFT_WEB_FONTS` (`FIGCRAFT_WEB_FONTS="Noto Sans Thai" trunk build --release`); each adds +its size to the wasm, which must stay under the 24 MiB gate. Packages carry each embedded font's licence as `OFL-.txt` (`copy_font_licences` in `packaging/env.sh`; `Contents/Resources/Licenses` in the macOS app). ### macOS From a7bb249813bcbbbc3c8991c64da59f8556578dae Mon Sep 17 00:00:00 2001 From: Salah1St <83543012+Salah1St@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:47:16 +0700 Subject: [PATCH 02/83] M7: warn when a FIGCRAFT_WEB_FONTS family isn't embedded A misspelt family name (or a CJK one without FIGCRAFT_EMBED_CJK) used to embed nothing without a word, and the web build then drew that script as missing-glyph boxes. With craft-fonts present, each named family that no manifest entry embedded now gets a cargo warning. Co-Authored-By: Claude Opus 5.5 --- crates/text/build.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/text/build.rs b/crates/text/build.rs index 624930002..2f5017563 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -29,6 +29,12 @@ fn main() { if !ok { println!("cargo::warning=building without craft-fonts (once it is there, `cargo clean -p figcraft-text` makes the next build use it)"); } + // A family named in FIGCRAFT_WEB_FONTS that nothing embedded is most likely misspelt. + for name in std::env::var("FIGCRAFT_WEB_FONTS").unwrap_or_default().split(',').map(str::trim).filter(|n| ok && !n.is_empty()) { + if !src.contains(&format!("family: {name:?},")) { + println!("cargo::warning=FIGCRAFT_WEB_FONTS: no craft-fonts manifest family {name:?} was embedded"); + } + } } src.push_str("];\n"); let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); From b278d8f541688475393d907e31fac2cd6792a046 Mon Sep 17 00:00:00 2001 From: Salah1St <83543012+Salah1St@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:58:25 +0700 Subject: [PATCH 03/83] M7: the FIGCRAFT_WEB_FONTS example needs a craft-fonts checkout with the family Review: the release pin has no Noto Sans Thai yet, so the example built against it only warns. The docs now say to build with a craft-fonts checkout that has the families named, and that a family the build doesn't embed gets a warning. Co-Authored-By: Claude Opus 5.5 --- docs/releasing.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/releasing.md b/docs/releasing.md index 0631dedfe..079319457 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -107,8 +107,10 @@ Noto Sans Georgian (craft-fonts' main has it but no Latin manifest), so desktop installed font; re-pin when one commit has both (craft-fonts#42). The web build embeds only Inter, Noto Sans Georgian and the Archivo SemiBold wordmark, when the pin has them. A distribution whose users write another script names the manifest families it needs, comma-separated, in -`FIGCRAFT_WEB_FONTS` (`FIGCRAFT_WEB_FONTS="Noto Sans Thai" trunk build --release`); each adds -its size to the wasm, which must stay under the 24 MiB gate. Packages carry each embedded font's licence as `OFL-.txt` +`FIGCRAFT_WEB_FONTS`, building with a craft-fonts checkout that has them (Noto Sans Thai is on +craft-fonts' main, not yet in the release pin): `FIGCRAFT_WEB_FONTS="Noto Sans Thai" trunk build +--release`. A named family the build doesn't embed gets a warning. Each one adds its size to the +wasm, which must stay under the 24 MiB gate. Packages carry each embedded font's licence as `OFL-.txt` (`copy_font_licences` in `packaging/env.sh`; `Contents/Resources/Licenses` in the macOS app). ### macOS From 031714fa95da0a0bc9554a88d6e2f405a73ee165 Mon Sep 17 00:00:00 2001 From: Salah1St <83543012+Salah1St@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:08:53 +0700 Subject: [PATCH 04/83] M7: the FIGCRAFT_WEB_FONTS warning also covers builds without craft-fonts Review: with no craft-fonts the check was skipped, so a named family went missing without a family-specific word. Every named family that nothing embedded now gets a warning that says why: no craft-fonts in the build, or no manifest entry embedded for it (misspelt, or CJK without FIGCRAFT_EMBED_CJK). Co-Authored-By: Claude Opus 5.5 --- crates/text/build.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/text/build.rs b/crates/text/build.rs index 2f5017563..32b8089c0 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -12,8 +12,8 @@ fn main() { println!("cargo::rerun-if-env-changed=FIGCRAFT_EMBED_CJK"); println!("cargo::rerun-if-env-changed=FIGCRAFT_WEB_FONTS"); let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); + let mut ok = false; if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { - let mut ok = false; for manifest in ["fonts/latin-manifest.txt", "fonts/manifest.txt"] { match craft_fonts(&dir, manifest) { Ok(entries) => { @@ -29,11 +29,17 @@ fn main() { if !ok { println!("cargo::warning=building without craft-fonts (once it is there, `cargo clean -p figcraft-text` makes the next build use it)"); } - // A family named in FIGCRAFT_WEB_FONTS that nothing embedded is most likely misspelt. - for name in std::env::var("FIGCRAFT_WEB_FONTS").unwrap_or_default().split(',').map(str::trim).filter(|n| ok && !n.is_empty()) { - if !src.contains(&format!("family: {name:?},")) { - println!("cargo::warning=FIGCRAFT_WEB_FONTS: no craft-fonts manifest family {name:?} was embedded"); - } + } + // Say which families named in FIGCRAFT_WEB_FONTS nothing embedded: with craft-fonts there the + // name is most likely misspelt. + for name in std::env::var("FIGCRAFT_WEB_FONTS").unwrap_or_default().split(',').map(str::trim).filter(|n| !n.is_empty()) { + if !src.contains(&format!("family: {name:?},")) { + let why = if ok { + "no craft-fonts manifest entry was embedded for it (misspelt? CJK also needs FIGCRAFT_EMBED_CJK)" + } else { + "this build has no craft-fonts" + }; + println!("cargo::warning=FIGCRAFT_WEB_FONTS: {name:?} isn't embedded: {why}"); } } src.push_str("];\n"); From 0b34cf335bc777f270b893e8f6ce5e850214803d Mon Sep 17 00:00:00 2001 From: Salah1St <83543012+Salah1St@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:50:12 +0700 Subject: [PATCH 05/83] M7: the web size gate is 24.5 MiB Review: packaging/web/package.sh caps the wasm at 25 MiB - 512 KiB since 0.12.1, not 24 MiB. Co-Authored-By: Claude Opus 5.5 --- docs/releasing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releasing.md b/docs/releasing.md index 03d42adfc..2c7f970af 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -127,7 +127,7 @@ allowlist: Inter, Noto Sans Georgian and Archivo SemiBold when supplied. A distr users write another script adds the manifest families it needs, comma-separated, with `FIGCRAFT_WEB_FONTS` (`FIGCRAFT_WEB_FONTS="Noto Sans Thai" trunk build --release`); a named family the build doesn't embed gets a warning, and each one adds its size to the wasm, which must stay -under the 24 MiB gate. Packages carry each embedded font's licence as `OFL-.txt` +under the 24.5 MiB gate in `packaging/web/package.sh`. Packages carry each embedded font's licence as `OFL-.txt` (`copy_font_licences` in `packaging/env.sh`; `Contents/Resources/Licenses` in the macOS app). ### macOS From 84d9391ba457429a3833333f25c57aea8d6d6f29 Mon Sep 17 00:00:00 2001 From: Carbocode Date: Fri, 30 Oct 2026 11:18:05 +0100 Subject: [PATCH 06/83] M8: align sidebar action icons --- crates/ui-egui/src/icons.rs | 2 ++ crates/ui-egui/src/icons/draw.rs | 19 +++++++++++++++++-- crates/ui-egui/src/panels/selection_colors.rs | 2 +- docs/ui-parity.md | 5 +++-- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index f8480127f..00542babd 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -29,6 +29,8 @@ pub use draw::{paint, paint_outlines}; icons! { Move, + /// Select layers using a colour: selection corners around a colour swatch. + SelectColor, Scale, FitContents, Hand, diff --git a/crates/ui-egui/src/icons/draw.rs b/crates/ui-egui/src/icons/draw.rs index 1d3925507..3c471ea85 100644 --- a/crates/ui-egui/src/icons/draw.rs +++ b/crates/ui-egui/src/icons/draw.rs @@ -280,6 +280,13 @@ fn draw(p: &P, icon: Icon) { use Icon::*; match icon { Move => p.fill(&[(4.0, 2.0), (4.0, 13.6), (6.9, 10.9), (8.8, 14.8), (10.6, 14.0), (8.7, 10.2), (12.6, 10.2)]), + SelectColor => { + p.poly(&[(2.5, 5.5), (2.5, 2.5), (5.5, 2.5)], false); + p.poly(&[(10.5, 2.5), (13.5, 2.5), (13.5, 5.5)], false); + p.poly(&[(2.5, 10.5), (2.5, 13.5), (5.5, 13.5)], false); + p.poly(&[(10.5, 13.5), (13.5, 13.5), (13.5, 10.5)], false); + p.circle(8.0, 8.0, 2.5); + } Scale => { p.rect(2.5, 7.5, 8.5, 13.5, 0.0); p.line(6.0, 10.0, 13.0, 3.0); @@ -387,8 +394,16 @@ fn draw(p: &P, icon: Icon) { ChevronRight => p.poly(&[(6.0, 4.5), (9.5, 8.0), (6.0, 11.5)], false), ChevronLeft => p.poly(&[(10.0, 4.5), (6.5, 8.0), (10.0, 11.5)], false), Plus => { - p.line(7.5, 2.0, 7.5, 13.0); - p.line(2.0, 7.5, 13.0, 7.5); + // Snap the shared centre once, then give every arm the same pixel length. + // Snapping each line's ends separately can offset its midpoint at small sizes. + let c = p.sp(8.0, 8.0); + let half = p.stroke.width / 2.0; + let reach = (5.0 * p.s / p.px).round() * p.px + half; + let mut mesh = Mesh::default(); + mesh.add_colored_rect(Rect::from_min_max(c - vec2(reach, half), c + vec2(reach, half)), p.color); + mesh.add_colored_rect(Rect::from_min_max(c - vec2(half, reach), c + vec2(half, -half)), p.color); + mesh.add_colored_rect(Rect::from_min_max(c + vec2(-half, half), c + vec2(half, reach)), p.color); + p.painter.add(Shape::mesh(mesh)); } Minus => p.line(2.0, 7.5, 13.0, 7.5), Eye | EyeOff => { diff --git a/crates/ui-egui/src/panels/selection_colors.rs b/crates/ui-egui/src/panels/selection_colors.rs index 9d7a058c2..bb4fe3d45 100644 --- a/crates/ui-egui/src/panels/selection_colors.rs +++ b/crates/ui-egui/src/panels/selection_colors.rs @@ -158,7 +158,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId } }); } - if icons::button(ui, Icon::Move, 24.0, false, tr("Select layers with this color")).clicked() { + if icons::button(ui, Icon::SelectColor, 24.0, false, tr("Select layers with this color")).clicked() { app.run("selection.selectColor", json!({"targets": group.targets})); } }); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9152466ee..f57b695b5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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); 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); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Selection colors uses an original outlined selection icon for selecting layers using a colour; the Plus icon’s bars share one snapped centre and equal arms at every size. 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); 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); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -73,7 +73,7 @@ interaction feel not yet compared. | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). No reorder rings for swapping layers; checked by its author on macOS and in the app on Windows (#402) | -| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). No library styles or variables from there, and no new style or variable from a row (#403) | +| Selection colors | partial | An outlined selection icon selects layers using the colour. for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). No library styles or variables from there, and no new style or variable from a row (#403) | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes | ## Tools @@ -175,6 +175,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8: Selection colors uses an original outlined selection icon for selecting layers using a colour; the Plus icon’s bars share one snapped centre and equal arms at every size. Readiness estimates unchanged. | | 2026-10-30 | minor | Pen into vector edit mode (#1430): Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on; Escape keeps the pen. Numbers unchanged. | | 2026-10-30 | minor | M8, #1574: the Actions palette (Ctrl+K) offered engine commands that need parameters it can't give, such as `image.place` ("Place image", needing the image's `data` or `path`), which failed with "Invalid parameters" and sat above the menu's Place image… row with the same label. A search now leaves out a command no menu lists when its params doc names a parameter without `?` (`figcraft_engine::cmd::needs_params`), and a row no menu item gives whose label reads like a menu item's (any case, without the ellipsis). Place image runs File ▸ Place image…, which opens the file dialog. Tests: `palette_tests.rs` (no row needs parameters unless a menu item runs it, no duplicate label across several searches), `unknown_params_tests.rs`. | | 2026-10-30 | minor | Preferences ▸ Scroll wheel zoom (#1390): a mouse wheel zooms about the pointer and Cmd/Ctrl+wheel scrolls; Shift+wheel, trackpads and pinches unchanged. Numbers unchanged. | From 967917dbbda7f3eebb28e23d5c14576c0674abc7 Mon Sep 17 00:00:00 2001 From: Carbocode Date: Fri, 30 Oct 2026 11:18:56 +0100 Subject: [PATCH 07/83] M8: share paint input with Selection colors --- crates/ui-egui/src/field_tests.rs | 28 +- crates/ui-egui/src/panels/right/paints.rs | 118 ++++---- crates/ui-egui/src/panels/selection_colors.rs | 253 ++++++++++++------ crates/ui-egui/src/widgets.rs | 27 ++ docs/ui-parity.md | 5 +- 5 files changed, 283 insertions(+), 148 deletions(-) diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index e1d17c271..6168ca5b9 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -187,13 +187,18 @@ fn setup() -> (Ui, NodeId, NodeId) { (u, a, b) } -/// The field named `name` in the Design panel: the middle of its value, and what it shows. +/// The topmost field named `name` in the Design panel: its value centre and displayed text. +/// Layer opacity precedes the equally named opacity fields in paint rows. fn field(u: &mut Ui, name: &str) -> (Pos2, String) { u.frame(vec![]); let out = u.frame_output(vec![]); let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; - let n = - nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::SpinButton && n.label() == Some(name)).unwrap_or_else(|| panic!("no field {name:?}")); + let n = nodes + .iter() + .map(|(_, n)| n) + .filter(|n| n.role() == Role::SpinButton && n.label() == Some(name)) + .min_by(|a, b| a.bounds().map_or(f64::INFINITY, |r| r.y0).total_cmp(&b.bounds().map_or(f64::INFINITY, |r| r.y0))) + .unwrap_or_else(|| panic!("no field {name:?}")); let b = n.bounds().expect("bounds"); (pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32), n.value().unwrap_or_default().to_string()) } @@ -203,11 +208,26 @@ fn label(u: &mut Ui, name: &str) -> Pos2 { u.frame(vec![]); let out = u.frame_output(vec![]); let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; - let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::SpinButton && n.label() == Some(name)).unwrap(); + let n = nodes + .iter() + .map(|(_, n)| n) + .filter(|n| n.role() == Role::SpinButton && n.label() == Some(name)) + .min_by(|a, b| a.bounds().map_or(f64::INFINITY, |r| r.y0).total_cmp(&b.bounds().map_or(f64::INFINITY, |r| r.y0))) + .unwrap(); let b = n.bounds().unwrap(); pos2(b.x0 as f32 - 12.0, ((b.y0 + b.y1) / 2.0) as f32) } +/// Layer and fill opacity are both named for assistive technology, even without a visible lead. +#[test] +fn paint_opacity_has_an_accessible_name() { + let (mut u, _, _) = setup(); + let output = u.frame_output(vec![]); + let nodes = output.platform_output.accesskit_update.unwrap().nodes; + let opacity_fields = nodes.iter().filter(|(_, n)| n.role() == Role::SpinButton && n.label() == Some("Opacity")).count(); + assert_eq!(opacity_fields, 2, "both layer and fill opacity must have an accessible name"); +} + fn geom(u: &Ui, n: NodeId) -> (f64, f64, f64, f64) { let doc = u.app.session.doc().unwrap(); let b = doc.world_bounds(n).unwrap(); diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index 11b368d1a..6f9e090ff 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -446,78 +446,62 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, let cmd = if fills { "fill.set" } else { "stroke.set" }; let r = row_rect(ui, 32.0, |ui| { let w = ui.available_width() - 60.0; - let (r, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover()); - ui.painter().rect_filled(r, CornerRadius::same(5), t.field); - let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0)); - widgets::paint_swatch(ui.painter(), sw, p); - let sresp = ui.interact(sw.expand(2.0), Id::unique(("swatch", fills, i)), Sense::click()); - app.picker_control(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, &sresp); - let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y)); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); - // The variable bound to this paint's colour (its key on the layers: `fill` or `fills//color`). - let bound = app.session.doc().ok().and_then(|d| { - let key = ids.first().and_then(|id| d.node(*id)).and_then(|n| figcraft_engine::cmd::lists::paint_binding_key(n, fills, i))?; - Some((key.to_string(), crate::variable_ui::bound_name(d, &ids, key)?)) - }); - match &p.kind { - PaintKind::Solid { .. } if bound.is_some() => { - let (key, name) = bound.clone().unwrap_or_default(); - let chip = Rect::from_min_max(pos2(text_r.min.x - 2.0, r.min.y + 3.0), pos2(text_r.max.x - 20.0, r.max.y - 3.0)); - child.painter().rect_filled(chip, CornerRadius::same(4), t.hover); - child.painter().rect_stroke(chip, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); - let short = name.rsplit('/').next().unwrap_or(&name).to_string(); - child.painter().with_clip_rect(chip.shrink2(vec2(5.0, 0.0))).text( - pos2(chip.min.x + 5.0, r.center().y), - Align2::LEFT_CENTER, - short, - regular(11.0), - t.text, - ); - let _ = ui.interact(chip, Id::unique(("bound_paint", fills, i)), Sense::hover()).on_hover_text(&name); - let dr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0)); - let dresp = ui.interact(dr, Id::unique(("detach_paint", fills, i)), Sense::click()).tip(tr("Detach variable")); - icons::paint(ui.painter(), dr.shrink(3.0), Icon::Detach, t.icon_secondary); - if dresp.clicked() { - app.run("variable.bind", json!({"ids": ids, "field": key, "variable": null})); + let (sresp, e) = widgets::paint_field(ui, Id::unique(("swatch", fills, i)), Id::unique(("pop", fills, i)), p, w, |child, text_r| { + let r = child.max_rect(); + // The variable bound to this paint's colour (its key on the layers: `fill` or `fills//color`). + let bound = app.session.doc().ok().and_then(|d| { + let key = ids.first().and_then(|id| d.node(*id)).and_then(|n| figcraft_engine::cmd::lists::paint_binding_key(n, fills, i))?; + Some((key.to_string(), crate::variable_ui::bound_name(d, &ids, key)?)) + }); + match &p.kind { + PaintKind::Solid { .. } if bound.is_some() => { + let (key, name) = bound.clone().unwrap_or_default(); + let chip = Rect::from_min_max(pos2(text_r.min.x - 2.0, r.min.y + 3.0), pos2(text_r.max.x - 20.0, r.max.y - 3.0)); + child.painter().rect_filled(chip, CornerRadius::same(4), t.hover); + child.painter().rect_stroke(chip, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let short = name.rsplit('/').next().unwrap_or(&name).to_string(); + child.painter().with_clip_rect(chip.shrink2(vec2(5.0, 0.0))).text( + pos2(chip.min.x + 5.0, r.center().y), + Align2::LEFT_CENTER, + short, + regular(11.0), + t.text, + ); + let _ = child.interact(chip, Id::unique(("bound_paint", fills, i)), Sense::hover()).on_hover_text(&name); + let dr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0)); + let dresp = child.interact(dr, Id::unique(("detach_paint", fills, i)), Sense::click()).tip(tr("Detach variable")); + icons::paint(child.painter(), dr.shrink(3.0), Icon::Detach, t.icon_secondary); + if dresp.clicked() { + app.run("variable.bind", json!({"ids": ids, "field": key, "variable": null})); + } } - } - PaintKind::Solid { color } => { - if let Some(h) = widgets::text_field_inline( - &mut child, - Id::unique(("hex", fills, i)), - &color.to_hex(), - text_r.shrink2(vec2(0.0, 4.0)), - regular(11.0), - ) && let Some(c) = figcraft_color::Color::from_hex_or_name(&h) - { - app.run(cmd, json!({"ids": ids, "index": i, "color": c.to_css_hex()})); + PaintKind::Solid { color } => { + if let Some(h) = widgets::text_field_inline( + child, + Id::unique(("hex", fills, i)), + &color.to_hex(), + text_r.shrink2(vec2(0.0, 4.0)), + regular(11.0), + ) && let Some(c) = figcraft_color::Color::from_hex_or_name(&h) + { + app.run(cmd, json!({"ids": ids, "index": i, "color": c.to_css_hex()})); + } } - } - _ => { - child.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, paint_label(p), regular(11.0), t.text); - // One layer's image fill: crop it on the canvas. - if fills && ids.len() == 1 && matches!(p.kind, PaintKind::Image(_)) { - let cr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0)); - let cresp = ui.interact(cr, Id::unique(("crop_image", i)), Sense::click()).tip(tr("Crop image")); - icons::paint(ui.painter(), cr.shrink(3.0), Icon::Crop, if cresp.hovered() { t.icon } else { t.icon_secondary }); - if cresp.clicked() { - app.run("image.crop", json!({"ids": ids, "index": i})); + _ => { + child.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, paint_label(p), regular(11.0), t.text); + // One layer's image fill: crop it on the canvas. + if fills && ids.len() == 1 && matches!(p.kind, PaintKind::Image(_)) { + let cr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0)); + let cresp = child.interact(cr, Id::unique(("crop_image", i)), Sense::click()).tip(tr("Crop image")); + icons::paint(child.painter(), cr.shrink(3.0), Icon::Crop, if cresp.hovered() { t.icon } else { t.icon_secondary }); + if cresp.clicked() { + app.run("image.crop", json!({"ids": ids, "index": i})); + } } } } - } - let op_r = Rect::from_min_max(pos2(r.max.x - 48.0, r.min.y), r.max); - ui.painter().line_segment([pos2(op_r.min.x, r.min.y + 4.0), pos2(op_r.min.x, r.max.y - 4.0)], Stroke::new(1.0, t.panel)); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(op_r)); - let e = widgets::number_field( - &mut child, - Id::unique(("pop", fills, i)), - Lead::None, - Some((f64::from(p.opacity) * 100.0).round()), - "%", - 48.0, - 1.0, - ); + }); + app.picker_control(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, &sresp); match e { Some(FieldEdit::Set(v)) => { app.run(cmd, json!({"ids": ids, "index": i, "opacity": (v / 100.0).clamp(0.0, 1.0)})); diff --git a/crates/ui-egui/src/panels/selection_colors.rs b/crates/ui-egui/src/panels/selection_colors.rs index bb4fe3d45..f2019ee86 100644 --- a/crates/ui-egui/src/panels/selection_colors.rs +++ b/crates/ui-egui/src/panels/selection_colors.rs @@ -24,6 +24,12 @@ struct Cache { colors: Arc>, } +#[derive(Clone)] +struct OpacityScrub { + targets: Vec, + opacity: f32, +} + /// Return the cached selection colours when the document and selection match. During an /// interaction reuse an existing matching cache, or return an empty list without discovering /// colours; otherwise discover and cache them. @@ -47,6 +53,7 @@ fn colors(ui: &Ui, document: u64, doc: &Arc, ids: &[NodeId], interacti colors } +/// Open or close the picker for frozen paint targets, anchored to the invoking control. pub(crate) fn toggle(app: &mut FigApp, targets: Vec, response: Option<&egui::Response>) { let Ok(ds) = app.session.ds() else { return }; let target = PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }; @@ -70,97 +77,147 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId let t = Tokens::get(ui.ctx()); widgets::section_header(ui, tr("Selection colors"), &[], false); // Bound the panel's height; a document with many distinct colors stays scrollable. - ui.add_enabled_ui(!interacting, |ui| { + let scrub_key = Id::unique("selection_color_scrub"); + if !interacting { + ui.data_mut(|d| d.remove::(scrub_key)); + } + let scrubbing = ui.data(|d| d.get_temp::(scrub_key)); + ui.add_enabled_ui(!interacting || scrubbing.is_some(), |ui| { egui::ScrollArea::vertical().id_salt("selection_colors_rows").max_height(240.0).show_rows(ui, 32.0, colors.len(), |ui, range| { for group in &colors[range] { ui.push_id((&group.targets, "selection_color"), |ui| { crate::right::row(ui, 32.0, |ui| { - let swatch = widgets::swatch(ui, &group.paint, 24.0).tip(tr("Edit colour")); - toggle(app, group.targets.clone(), Some(&swatch)); - let width = (ui.available_width() - 116.0).max(40.0); let linked_name = group .style .as_ref() .and_then(|id| doc.style(id).map(|s| s.name.as_str())) .or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get(id).map(|v| v.name.as_str()))); - if let Some(name) = linked_name { - let response = widgets::dropdown(ui, name, width, None); - toggle(app, group.targets.clone(), Some(&response)); - } else if let Some(color) = group.paint.solid_color() { - if let Some(hex) = widgets::text_field_named(ui, ui.scope_id().with("hex"), &color.to_hex(), width, tr("Color")) { - if let Some(color) = Color::from_hex(&hex) { - let mut paint = group.paint.clone(); - paint.kind = figcraft_color::PaintKind::Solid { color: color.with_alpha(1.0) }; - if color.a < 1.0 { - paint.opacity = color.a; - } - app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint})); - } else { - app.toast(tr("Invalid color")); - } - } - } else { - let response = widgets::dropdown(ui, &widgets::paint_name(&group.paint), width, None); - toggle(app, group.targets.clone(), Some(&response)); - } - let alpha = format!("{}%", figcraft_doc::trim_num(f64::from(group.paint.opacity) * 100.0)); - if let Some(value) = widgets::text_field_named(ui, ui.scope_id().with("opacity"), &alpha, 44.0, tr("Opacity")) - && let Ok(value) = value.trim().trim_end_matches('%').parse::() - && value.is_finite() + let width = ui.available_width() - 60.0; + let mut paint = group.paint.clone(); + if let Some(scrub) = &scrubbing + && scrub.targets == group.targets { - let mut paint = group.paint.clone(); - paint.opacity = (value / 100.0).clamp(0.0, 1.0); - app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint})); + paint.opacity = scrub.opacity; } - if linked_name.is_some() { - let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") }; - if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() { - app.run("selection.detachColor", json!({"targets": group.targets})); - } - } else { - let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles")); - crate::right::menu_popup(&styles).show(|ui| { - let mut any = false; - for style in &doc.styles { - if !matches!(style.kind, StyleKind::Paint { .. }) { - continue; - } - any = true; - if crate::right::menu_item(ui, &style.name, "") { - app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id})); - app.ui.picker = None; - ui.close(); + let mut name_response = None; + let (swatch, opacity) = widgets::paint_field( + ui, + ui.scope_id().with("paint_field"), + ui.scope_id().with("opacity"), + &paint, + width, + |child, rect| { + if linked_name.is_some() || group.paint.solid_color().is_none() { + let name = linked_name.map(str::to_owned).unwrap_or_else(|| widgets::paint_name(&group.paint)); + child.painter().with_clip_rect(rect).text( + rect.left_center(), + egui::Align2::LEFT_CENTER, + &name, + crate::theme::regular(11.0), + t.text, + ); + name_response = Some(child.interact(rect, child.scope_id().with("paint_name"), egui::Sense::click()).tip(name)); + } else if let Some(color) = group.paint.solid_color() + && let Some(hex) = widgets::text_field_inline( + child, + child.scope_id().with("hex"), + &color.to_hex(), + rect.shrink2(egui::vec2(0.0, 4.0)), + crate::theme::regular(11.0), + ) + { + if let Some(color) = Color::from_hex_or_name(&hex) { + let mut paint = group.paint.clone(); + paint.kind = figcraft_color::PaintKind::Solid { color: color.with_alpha(1.0) }; + if color.a < 1.0 { + paint.opacity = color.a; + } + app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint})); + } else { + app.toast(tr("Invalid color")); } } - if !any { - widgets::label(ui, tr("No color styles"), t.text_secondary); - } - let variables: Vec<_> = doc - .variables - .variables - .iter() - .filter(|v| { - v.kind == VariableType::Color - && bind_refusal(doc, &group.targets, &v.id).is_none() - && in_scope(doc, &group.targets, &v.id) - }) - .collect(); - if !variables.is_empty() { - ui.separator(); - widgets::label(ui, tr("Color variables"), t.text_secondary); - } - for v in variables { - if crate::right::menu_item(ui, &v.name, "") { - app.run("selection.bindColorVariable", json!({"targets": group.targets, "variable": v.id})); - app.ui.picker = None; - ui.close(); - } + }, + ); + toggle(app, group.targets.clone(), Some(&swatch)); + if let Some(response) = name_response { + toggle(app, group.targets.clone(), Some(&response)); + } + if let Some(edit) = opacity { + let (value, scrub, done) = match edit { + widgets::FieldEdit::Set(value) => (value, false, false), + widgets::FieldEdit::Scrub(value, done) => (value, true, done), + }; + let mut paint = group.paint.clone(); + paint.opacity = (value / 100.0).clamp(0.0, 1.0) as f32; + ui.data_mut(|d| { + if scrub && !done { + d.insert_temp(scrub_key, OpacityScrub { targets: group.targets.clone(), opacity: paint.opacity }); + } else { + d.remove::(scrub_key); } }); + if scrub { + let _ = app.session.begin_interaction("Opacity"); + let _ = app.session.execute("selection.setPaint", &json!({"targets": group.targets, "paint": paint})); + if done { + let _ = app.session.commit_interaction(); + } + } else { + app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint})); + } } - if icons::button(ui, Icon::SelectColor, 24.0, false, tr("Select layers with this color")).clicked() { - app.run("selection.selectColor", json!({"targets": group.targets})); - } + widgets::tight_end(ui, |ui| { + if linked_name.is_some() { + let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") }; + if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() { + app.run("selection.detachColor", json!({"targets": group.targets})); + } + } else { + let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles")); + crate::right::menu_popup(&styles).show(|ui| { + let mut any = false; + for style in &doc.styles { + if !matches!(style.kind, StyleKind::Paint { .. }) { + continue; + } + any = true; + if crate::right::menu_item(ui, &style.name, "") { + app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id})); + app.ui.picker = None; + ui.close(); + } + } + if !any { + widgets::label(ui, tr("No color styles"), t.text_secondary); + } + let variables: Vec<_> = doc + .variables + .variables + .iter() + .filter(|v| { + v.kind == VariableType::Color + && bind_refusal(doc, &group.targets, &v.id).is_none() + && in_scope(doc, &group.targets, &v.id) + }) + .collect(); + if !variables.is_empty() { + ui.separator(); + widgets::label(ui, tr("Color variables"), t.text_secondary); + } + for v in variables { + if crate::right::menu_item(ui, &v.name, "") { + app.run("selection.bindColorVariable", json!({"targets": group.targets, "variable": v.id})); + app.ui.picker = None; + ui.close(); + } + } + }); + } + if icons::button(ui, Icon::SelectColor, 24.0, false, tr("Select layers with this color")).clicked() { + app.run("selection.selectColor", json!({"targets": group.targets})); + } + }); }); }); } @@ -300,6 +357,52 @@ mod tests { assert!(p.app.ui.last_error.is_none()); } + #[test] + fn shared_color_field_edits_hex_and_opacity_and_undoes_each_edit() { + let (mut p, a, _) = Panel::new(); + let red = p.frame(vec![]).into_iter().find(|(s, _)| s == "FF0000").unwrap().1; + p.click(red); + p.frame(vec![Event::Text("00FF00".into())]); + let enter = Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + p.frame(vec![enter.clone()]); + assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00"); + p.frame(vec![]); + p.click(Pos2::new(176.0, red.y)); + p.frame(vec![Event::Text("45".into())]); + p.frame(vec![enter]); + assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity, 0.45); + p.app.run("edit.undo", json!({})); + assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity, 1.0); + p.app.run("edit.undo", json!({})); + assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000"); + } + + #[test] + fn shared_opacity_field_keeps_dragging_until_release_as_one_undo_step() { + let (mut p, a, _) = Panel::new(); + let red = p.frame(vec![]).into_iter().find(|(s, _)| s == "FF0000").unwrap().1; + let start = Pos2::new(167.0, red.y); + p.frame(vec![ + Event::PointerMoved(start), + Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + ]); + for dx in [-10.0, -20.0, -30.0] { + p.frame(vec![Event::PointerMoved(start + vec2(dx, 0.0))]); + } + assert!(p.frame(vec![]).iter().any(|(text, _)| text == "70%"), "the live opacity stays visible while scrubbing"); + p.frame(vec![Event::PointerButton { + pos: start - vec2(30.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }]); + assert!(p.app.session.ds().unwrap().interaction.is_none()); + assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity, 0.7); + p.app.run("edit.undo", json!({})); + assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity, 1.0); + assert_eq!(p.app.session.selection().len(), 2); + } + #[test] fn panel_displays_the_linked_style_name_and_the_other_color() { let (mut p, a, b) = Panel::new(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b6b4b7e88..ee6349b95 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1016,6 +1016,33 @@ pub fn checker(painter: &egui::Painter, rect: Rect, cell: f32) { } } +/// Shared Fill/Stroke/Selection colors control: one background, a 16 pt swatch, +/// caller-provided colour content, and a separated, scrubbable opacity field. +/// The caller owns picker routing and commands; this widget only draws and reports edits. +pub fn paint_field( + ui: &mut Ui, + id: Id, + opacity_id: Id, + paint: &figcraft_color::Paint, + width: f32, + content: impl FnOnce(&mut Ui, Rect), +) -> (Response, Option) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(5), t.field); + let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0)); + paint_swatch(ui.painter(), sw, paint); + let swatch = ui.interact(sw.expand(2.0), id, Sense::click()).tip(tr("Edit colour")); + let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); + content(&mut child, text_r); + let op_r = Rect::from_min_max(pos2(r.max.x - 48.0, r.min.y), r.max); + ui.painter().line_segment([pos2(op_r.min.x, r.min.y + 4.0), pos2(op_r.min.x, r.max.y - 4.0)], Stroke::new(1.0, t.panel)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(op_r)); + let opacity = number_field(&mut child, opacity_id, Lead::Named(tr("Opacity")), Some((f64::from(paint.opacity) * 100.0).round()), "%", 48.0, 1.0); + (swatch, opacity) +} + /// A paint swatch (with checkerboard under transparent colours). pub fn swatch(ui: &mut Ui, paint: &figcraft_color::Paint, size: f32) -> Response { swatch_named(ui, paint, size, tr("Color")) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f57b695b5..fb0548e1c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Selection colors uses an original outlined selection icon for selecting layers using a colour; the Plus icon’s bars share one snapped centre and equal arms at every size. 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); 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); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Selection colors shares Fill and Stroke’s swatch/colour/opacity control, including live opacity scrubbing with one undo step. Selection colors uses an original outlined selection icon for selecting layers using a colour; the Plus icon’s bars share one snapped centre and equal arms at every size; 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); 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); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -73,7 +73,7 @@ interaction feel not yet compared. | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). No reorder rings for swapping layers; checked by its author on macOS and in the app on Windows (#402) | -| Selection colors | partial | An outlined selection icon selects layers using the colour. for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). No library styles or variables from there, and no new style or variable from a row (#403) | +| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart); each row shares Fill and Stroke’s colour control, and an outlined selection icon selects the layers that use its colour. Editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a colour style or colour variable of the document, and a row linked to a style or variable detaches it, keeping the colour (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). No library styles or variables from there, and no new style or variable from a row (#403) | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes | ## Tools @@ -175,6 +175,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8: Selection colors shares Fill and Stroke’s swatch/colour/opacity control, including live opacity scrubbing with one undo step. Verified in light and dark themes; colour edits, opacity scrubbing and undo covered by panel tests. Readiness estimates unchanged. | | 2026-10-30 | minor | M8: Selection colors uses an original outlined selection icon for selecting layers using a colour; the Plus icon’s bars share one snapped centre and equal arms at every size. Readiness estimates unchanged. | | 2026-10-30 | minor | Pen into vector edit mode (#1430): Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on; Escape keeps the pen. Numbers unchanged. | | 2026-10-30 | minor | M8, #1574: the Actions palette (Ctrl+K) offered engine commands that need parameters it can't give, such as `image.place` ("Place image", needing the image's `data` or `path`), which failed with "Invalid parameters" and sat above the menu's Place image… row with the same label. A search now leaves out a command no menu lists when its params doc names a parameter without `?` (`figcraft_engine::cmd::needs_params`), and a row no menu item gives whose label reads like a menu item's (any case, without the ellipsis). Place image runs File ▸ Place image…, which opens the file dialog. Tests: `palette_tests.rs` (no row needs parameters unless a menu item runs it, no duplicate label across several searches), `unknown_params_tests.rs`. | From 1aa6b81d017fb0c7e60864b85665c74a92d400b3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:23:54 +0100 Subject: [PATCH 08/83] Reject invalid entries in vector.create points instead of dropping them Found while testing crates/engine/src/cmd/create.rs:~286 (create, the `points` branch using filter_map) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/create.rs | 9 ++++++++- crates/engine/src/draw_tools_tests.rs | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 9caf4551a..52b34e101 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -302,7 +302,14 @@ pub fn create(s: &mut Session, p: &Value) -> Result { } (w, h, offset) = (shape.width, shape.height, shape.offset); } else if let Some(Value::Array(pts)) = get(p, "points") { - let pts: Vec = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect(); + let pts: Vec = pts + .iter() + .map(|v| { + let xy = v.as_array().filter(|a| a.len() == 2)?; + Some(Point::new(f64_of(&xy[0])?, f64_of(&xy[1])?)) + }) + .collect::>() + .ok_or_else(|| bad(cmd, "`points` entries must be numeric [x, y] pairs"))?; if pts.len() < 2 { return Err(bad(cmd, "`points` needs at least two [x, y] pairs")); } diff --git a/crates/engine/src/draw_tools_tests.rs b/crates/engine/src/draw_tools_tests.rs index c5b84d210..6a7b47b59 100644 --- a/crates/engine/src/draw_tools_tests.rs +++ b/crates/engine/src/draw_tools_tests.rs @@ -378,3 +378,12 @@ fn the_text_on_a_path_tool_carries_a_fill_bound_through_fills() { let t = editing(&s).expect("typing starts on the path"); assert_eq!(s.doc().unwrap().node(t).unwrap().bound, bound, "the fill keeps following its variable"); } + +#[test] +fn bad_point_is_rejected() { + let mut s = Session::new(); + assert!(s.execute("vector.create", &json!({"points": [[0, 0], ["abc", 5], [100, 100]], "closed": false})).is_err()); + assert!(s.execute("vector.create", &json!({"points": [[0, 0], [1], [100, 100]]})).is_err()); + assert!(s.execute("vector.create", &json!({"points": [[0, 0], 7, [100, 100]]})).is_err()); + assert!(s.execute("vector.create", &json!({"points": [[0, 0], [50, 5], [100, 100]]})).is_ok()); +} From 650601593e02d6ef687dd26becac92907bc5a335 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:05:43 +0100 Subject: [PATCH 09/83] Reject negative or fractional index in page.move Found while testing crates/engine/src/cmd/page.rs:132 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/page.rs | 2 +- crates/engine/src/panels_behaviour_tests.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 2835deff3..458e7572c 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -129,7 +129,7 @@ pub fn specs() -> Vec { }), spec!("page.move", "Move page", "{id?, index}", true, |s, p| { let id = id(p, "id").unwrap_or(s.page()?); - let k = req_num("page.move", p, "index")?.max(0.0) as usize; + let k = req_index("page.move", p, "index")?; let doc = s.doc_mut()?; if !doc.pages.contains(&id) { return Err(bad("page.move", format!("{id} is not a page"))); diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs index d97d25075..e44d99fc0 100644 --- a/crates/engine/src/panels_behaviour_tests.rs +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -393,6 +393,20 @@ fn page_select_and_doc_switch_refuse_negative_or_fractional_indexes() { } } +#[test] +fn page_move_refuses_negative_or_fractional_indexes() { + let mut s = Session::new(); + ex(&mut s, "page.add", json!({})); + let p3 = id(&ex(&mut s, "page.add", json!({}))); + let before = pages(&mut s); + for bad_index in [json!(-1), json!(1.5)] { + assert!(s.execute("page.move", &json!({"id": p3, "index": bad_index})).is_err(), "page.move index {bad_index} must fail"); + assert_eq!(pages(&mut s), before, "page.move index {bad_index} leaves the order alone"); + } + ex(&mut s, "page.move", json!({"id": p3, "index": 0})); + assert_eq!(pages(&mut s)[0]["id"], json!(p3), "a valid index still moves the page"); +} + #[test] fn assets_panel_lists_files_and_drops_by_the_documented_rules() { let mut s = Session::new(); From f26226590ad579f0dcf9931d842050e915e6688e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:13:36 +0100 Subject: [PATCH 10/83] Reject negative and fractional index in variable.moveMode Found while testing crates/engine/src/cmd/variables.rs:267 (move_mode) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/variables.rs | 6 +++--- crates/engine/src/variables_behaviour_tests.rs | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 59ad46ced..75607ae64 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -12,7 +12,7 @@ use serde_json::{Value, json}; use super::lists::BoundList; use super::{CommandSpec, ok}; -use crate::params::{boolean, get, num, req_string, string}; +use crate::params::{boolean, get, req_index, req_string, string}; use crate::{Result, Session, bad, spec}; pub fn specs() -> Vec { @@ -258,13 +258,13 @@ fn remove_mode(s: &mut Session, p: &Value) -> Result { fn move_mode(s: &mut Session, p: &Value) -> Result { let cmd = "variable.moveMode"; - let to = num(p, "to").filter(|x| x.is_finite()).ok_or_else(|| bad(cmd, "`to` must be a number"))?; + let to = req_index(cmd, p, "to")?; let doc = s.doc_mut()?; let (ci, mode) = mode_of(doc, cmd, p)?; let coll = doc.variables.collections.get_mut(ci).ok_or_else(|| bad(cmd, "no such collection"))?; let from = coll.modes.iter().position(|m| m.id == mode).ok_or_else(|| bad(cmd, "no such mode"))?; let m = coll.modes.remove(from); - let at = (to.max(0.0) as usize).min(coll.modes.len()); + let at = to.min(coll.modes.len()); coll.modes.insert(at, m); ok() } diff --git a/crates/engine/src/variables_behaviour_tests.rs b/crates/engine/src/variables_behaviour_tests.rs index 306a8fba9..2717726f8 100644 --- a/crates/engine/src/variables_behaviour_tests.rs +++ b/crates/engine/src/variables_behaviour_tests.rs @@ -68,6 +68,20 @@ fn types_and_their_starting_values() { assert!(s.execute("variable.create", &json!({"collection": "C", "name": "y", "type": "number", "value": "wide"})).is_err()); } +#[test] +fn move_mode_rejects_negative_and_fractional_indexes() { + let mut s = Session::new(); + theme(&mut s); + run(&mut s, "variable.addMode", json!({"collection": "Theme", "name": "Dim"})); + let order = + |s: &Session| -> Vec { s.doc().unwrap().variables.collection("Theme").unwrap().modes.iter().map(|m| m.name.clone()).collect() }; + let before = order(&s); + for to in [json!(-1), json!(1.9)] { + assert!(s.execute("variable.moveMode", &json!({"collection": "Theme", "mode": "Dim", "to": to})).is_err(), "{to}"); + assert_eq!(order(&s), before, "{to}"); + } +} + #[test] fn modes_new_duplicated_reordered_and_the_default() { // Documented: a new mode starts with the default mode's values and a duplicated one with its From 41ec33eeec0b1f102453adc62d7729ea2064144d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:15:23 +0100 Subject: [PATCH 11/83] Reject negative and fractional indexes in node.reorder Found while testing crates/engine/src/cmd/edit.rs:329 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/al_nesting_tests.rs | 13 +++++++++++++ crates/engine/src/cmd/edit.rs | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/al_nesting_tests.rs b/crates/engine/src/al_nesting_tests.rs index f5d607ecf..f3ab62a06 100644 --- a/crates/engine/src/al_nesting_tests.rs +++ b/crates/engine/src/al_nesting_tests.rs @@ -345,3 +345,16 @@ fn remove_all_auto_layout_strips_the_frame_and_every_frame_nested_in_it() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(laid_out(&s), [true, true, true]); } + +#[test] +fn reorder_rejects_negative_and_fractional_indexes() { + let mut s = Session::new(); + let f = frame(&mut s, None, 0.0, 0.0, 200.0, 200.0); + let a = rect(&mut s, Some(f), 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, Some(f), 0.0, 0.0, 10.0, 10.0); + let c = rect(&mut s, Some(f), 0.0, 0.0, 10.0, 10.0); + for bad in [json!(-1), json!(0.5), json!("x")] { + assert!(s.execute("node.reorder", &json!({"id": c, "index": bad})).is_err(), "{bad} accepted"); + assert_eq!(kids(&s, f), vec![a, b, c]); + } +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 700fa7414..2aaa696b3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -326,7 +326,7 @@ pub fn specs() -> Vec { ), spec!("node.reorder", "Reorder layer", "{id, index}", true, |s, p| { let i = req_id("node.reorder", p, "id")?; - let idx = req_num("node.reorder", p, "index")?.max(0.0) as usize; + let idx = req_index("node.reorder", p, "index")?; s.doc_mut()?.reorder(i, idx)?; ok() }), From 19afdd21f9f63e2a66a5c9cd8ea9f7f712b9b66d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 20:44:37 -0500 Subject: [PATCH 12/83] fix(format): import a .fig blob once however many hashes name it Distinct image hashes pointing at one embedded blob shared its copy, but the import resolved each hash separately: the blob was copied out and hashed again for every alias. Aliases without an images/ member of their own are now renamed to the first hash naming the blob before layers are built, so the blob is copied and hashed once. Image paints also spelled out their whole hash as hex for every layer, whatever its length; hashes longer than any image can be named by (64 bytes) now resolve to nothing, as a missing hash does. --- crates/format/src/fig.rs | 62 ++++++++++++++++++- .../format/src/fig/map/image_filter_tests.rs | 12 +++- crates/format/src/fig/map/paint.rs | 13 +++- crates/format/src/fig/tests.rs | 38 ++++++++++++ 4 files changed, 119 insertions(+), 6 deletions(-) diff --git a/crates/format/src/fig.rs b/crates/format/src/fig.rs index 55a5d7c34..2a8b542e9 100644 --- a/crates/format/src/fig.rs +++ b/crates/format/src/fig.rs @@ -72,6 +72,9 @@ use kiwi::{Kv, Schema}; /// Largest `canvas.fig` member read, and largest decompressed chunk. const MAX_CANVAS: u64 = 256 * 1024 * 1024; const MAX_CHUNK: u64 = 512 * 1024 * 1024; +/// Longest image hash (bytes) an image is found by: `images/` names and embedded images both +/// stop at 64 bytes (128 hex digits). +pub(crate) const MAX_IMAGE_HASH: usize = 64; /// Largest image member read. const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES; /// Total decompressed ZIP image bytes read per import: the native image budget (`IMAGE_BUDGET`) on @@ -138,10 +141,14 @@ fn import_with_image_limit(bytes: &[u8], name: &str, image_limit: u64) -> Result let max_nodes = layer_budget(packed); drop(message); let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name); - let (json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes); + let (mut json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes); // The message goes before the layers are built, so the images it carries are copied out first. let embedded = embedded_copies(&msg); drop(msg); + let aliases = embedded_aliases(&embedded, |hash| archive.has_image(hash)); + if !aliases.is_empty() { + canonical_image_refs(&mut json, &aliases); + } let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec())); let (mut doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?; let skipped = archive.image_budget.skipped.get(); @@ -224,9 +231,53 @@ fn embedded_copies(msg: &Kv) -> BTreeMap> { out } +/// Embedded images ([`embedded_copies`]) whose blob an earlier hash already names, each with that +/// hash. Hashes that also have an `images/` member keep their own name: the member is read first. +/// Pointing every alias at one name has the import copy and hash each blob once, however many +/// hashes a file points at it. +fn embedded_aliases(embedded: &BTreeMap>, in_zip: impl Fn(&str) -> bool) -> BTreeMap { + let mut first: BTreeMap = BTreeMap::new(); + let mut out = BTreeMap::new(); + for (hash, bytes) in embedded { + if in_zip(hash) { + continue; + } + // Hashes naming one blob share its copy, so the copy's address tells the blob. + let canonical = *first.entry(bytes.as_ptr() as usize).or_insert(hash); + if canonical != hash { + out.insert(hash.clone(), canonical.clone()); + } + } + out +} + +/// Rename every `imageRef` in REST `json` found in `aliases` to the name it maps to. +fn canonical_image_refs(json: &mut serde_json::Value, aliases: &BTreeMap) { + use serde_json::Value; + let mut pending = vec![json]; + while let Some(value) = pending.pop() { + match value { + Value::Object(fields) => { + for (key, field) in fields.iter_mut() { + match field { + Value::String(r) if key == "imageRef" => { + if let Some(canonical) = aliases.get(r.as_str()) { + r.clone_from(canonical); + } + } + _ => pending.push(field), + } + } + } + Value::Array(items) => pending.extend(items.iter_mut()), + _ => {} + } + } +} + /// The hash and blob index of an `Image` value whose `dataBlob` points at a usable blob. fn embedded_image(image: &Kv, blobs: &[Kv]) -> Option<(String, usize)> { - let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= 64)?; + let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= MAX_IMAGE_HASH)?; let Some(Kv::Uint(index)) = image.get("dataBlob") else { return None }; let index = usize::try_from(*index).ok()?; let bytes = blob(blobs, index)?; @@ -272,10 +323,15 @@ impl<'b> Archive<'b> { Ok(Archive { canvas, file_name, zip: Some(z), image_budget }) } + /// Whether `images/` has a member for image `hash`. + fn has_image(&self, hash: &str) -> bool { + self.zip.as_ref().is_some_and(|z| z.index_for_name(&format!("images/{hash}")).is_some()) + } + /// Bytes of image `hash` (lowercase hex) from `images/`. fn image(&self, hash: &str) -> Option> { let budget = &self.image_budget; - if hash.is_empty() || hash.len() > 128 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { + if hash.is_empty() || hash.len() > 2 * MAX_IMAGE_HASH || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } // A clone shares the parsed directory and the bytes; the resolver only has shared access. diff --git a/crates/format/src/fig/map/image_filter_tests.rs b/crates/format/src/fig/map/image_filter_tests.rs index a8db39a77..e13a56e32 100644 --- a/crates/format/src/fig/map/image_filter_tests.rs +++ b/crates/format/src/fig/map/image_filter_tests.rs @@ -1,4 +1,5 @@ -//! Malformed image paint filters are skipped with a warning. +//! Malformed image paint filters are skipped with a warning, and image hashes too long to name +//! an image aren't spelled out. use super::*; @@ -25,3 +26,12 @@ fn boolean_filter_values_are_malformed_even_when_false() { assert!(warnings.iter().any(|warning| warning.contains(expected_warning)), "{warnings:?}"); } } + +#[test] +fn an_oversized_image_hash_is_not_spelled_out() { + let paint = |len: usize| Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("image", Kv::Object(vec![("hash", Kv::Bytes(vec![0xab; len]))]))]); + let image_ref = |len: usize| super::paint(&paint(len), &mut |_| {}).and_then(|p| p.get("imageRef").cloned()); + assert_eq!(image_ref(20), Some(Value::String("ab".repeat(20)))); + assert_eq!(image_ref(crate::fig::MAX_IMAGE_HASH), Some(Value::String("ab".repeat(crate::fig::MAX_IMAGE_HASH)))); + assert_eq!(image_ref(crate::fig::MAX_IMAGE_HASH + 1), Some(Value::String(String::new()))); +} diff --git a/crates/format/src/fig/map/paint.rs b/crates/format/src/fig/map/paint.rs index 5b7121757..cf96c2bf0 100644 --- a/crates/format/src/fig/map/paint.rs +++ b/crates/format/src/fig/map/paint.rs @@ -162,8 +162,17 @@ pub(crate) fn paint(p: &Kv, warn: &mut dyn FnMut(String)) -> Option { o.insert("gradientStops".into(), Value::Array(stops)); } "IMAGE" => { - let hash: String = - p.get("image").and_then(|i| i.get("hash")).and_then(Kv::bytes).unwrap_or_default().iter().map(|b| format!("{b:02x}")).collect(); + // A hash longer than any the file's images can be named by resolves to nothing: it + // isn't spelled out, which a shared style would repeat for every layer using it. + let hash: String = p + .get("image") + .and_then(|i| i.get("hash")) + .and_then(Kv::bytes) + .filter(|h| h.len() <= crate::fig::MAX_IMAGE_HASH) + .unwrap_or_default() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); o.insert("imageRef".into(), json!(hash)); let mode = p.get("imageScaleMode").and_then(Kv::str).unwrap_or("FILL"); o.insert("scaleMode".into(), json!(mode)); diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 657f2f81d..2afae4361 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -795,6 +795,44 @@ fn hashes_naming_one_blob_share_one_copy_of_it() { assert_eq!(*copies[&"63".repeat(20)], [7; 16]); } +/// Hashes naming one blob are imported as one image: every alias is renamed to the first hash +/// before layers are built, so the blob is copied and hashed once, not once per hash. +#[test] +fn hashes_naming_one_blob_are_imported_once() { + let rect = |i: u64, hash: u8, blob: u64| { + let paint = o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(vec![hash; 20])), ("dataBlob", u(blob))]))]); + node(i, Some((1, &format!("{i:04}"))), "RECTANGLE", &format!("R{i}"), vec![("size", xy(10.0, 10.0)), ("fillPaints", arr(vec![paint]))]) + }; + let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]; + nodes.extend((2..42).map(|i| rect(i, i as u8, 0))); + let msg = o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![o(vec![("bytes", Kv::Bytes(png()))])]))]); + let copies = embedded_copies(&msg); + let aliases = embedded_aliases(&copies, |_| false); + assert_eq!(aliases.len(), 39); + assert!(aliases.values().all(|c| *c == "02".repeat(20))); + // A hash `images/` also holds keeps its own name. + assert!(!embedded_aliases(&copies, |h| h == "05".repeat(20)).contains_key(&"05".repeat(20))); + let (doc, warnings) = import(&canvas(&schema(), &msg), "x").unwrap(); + assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); + assert_eq!(doc.images.len(), 1); + let key = doc.images.keys().next().unwrap(); + for i in 2..42 { + let figcraft_doc::color::PaintKind::Image(img) = &named(&doc, &format!("R{i}")).fills[0].kind else { panic!("not an image fill") }; + assert_eq!(&img.image, key); + } +} + +#[test] +fn image_refs_are_renamed_wherever_they_sit() { + let aliases: BTreeMap = [("b".to_string(), "a".to_string())].into(); + let mut json = serde_json::json!({"fills": [{"imageRef": "b"}, {"imageRef": "c"}], "styles": {"s": {"fills": [{"imageRef": "b"}]}}, "name": "b"}); + canonical_image_refs(&mut json, &aliases); + assert_eq!( + json, + serde_json::json!({"fills": [{"imageRef": "a"}, {"imageRef": "c"}], "styles": {"s": {"fills": [{"imageRef": "a"}]}}, "name": "b"}) + ); +} + mod image_budget { use super::*; From 421a8e5126a56ede1cdce45d5b53289c96a848f9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 04:34:50 +0000 Subject: [PATCH 13/83] fix(doc): stop frame_relative on a looping parent chain A restored dev snapshot can replace an ancestor with a historical descendant while the root keeps that ancestor as its parent; frame_relative walked the parent chain without a guard and never returned. It now stops after more hops than the document has nodes, as is_ancestor does. --- crates/doc/src/lib.rs | 7 +++++++ crates/doc/src/tests.rs | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 89eb97269..b637aa8f7 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -585,7 +585,14 @@ impl Document { let Some(n) = self.node(id) else { return Affine::IDENTITY }; let mut t = n.transform; let mut parent = n.parent; + // More hops than nodes means the parent chain loops (a restored snapshot can leave one): + // stop rather than spin. + let mut hops = 0; while let Some(p) = parent.and_then(|p| self.node(p)).filter(|p| p.kind.bounds_follow_children()) { + hops += 1; + if hops > self.nodes.len() { + break; + } t = p.transform * t; parent = p.parent; } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a0e3e17b0..4e55336b6 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -846,3 +846,16 @@ fn the_colour_walker_visits_calculation_literals() { let ColorOperand::Literal { literal } = from else { panic!("literal operand") }; assert_eq!(literal.a, 0.5, "the mix's stored literal was converted like every other colour"); } + +#[test] +fn frame_relative_ends_on_a_looping_parent_chain() { + let mut d = Document::new("x"); + let p = page(&d); + let f = d.create(p, NodeKind::Group, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap(); + let mut v = serde_json::to_value(&d).unwrap(); + // F's parent is its own child G (as a restored snapshot can leave things). + v["nodes"][f.to_string()]["parent"] = serde_json::json!(g); + let bad: Document = serde_json::from_value(v).unwrap(); + let _ = bad.frame_relative(g); +} From 6cb724c588d4abcd0a30a8b695431818f2b2ce90 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 3 Nov 2026 12:03:42 +0500 Subject: [PATCH 14/83] docs: credit Hamza Naveed in contributor names --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index c846d8725..16c4de243 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -101,3 +101,7 @@ sync_github_name = true [people.aleksei-suholita] real_name = "Oleksii Sukholita" + +[people.Hamza0101] +real_name = "Hamza Naveed" +display_name = "Hamza0101" From 58af1a6237228125fe60e05fc4056ccd9a2f1ae3 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 3 Nov 2026 12:23:25 +0500 Subject: [PATCH 15/83] M4, #1747: a text layer's name follows the characters typed into it --- crates/engine/src/canvas/textedit.rs | 7 +++ crates/engine/src/cmd/props.rs | 24 ++++++++ crates/engine/src/text_behaviour_tests.rs | 68 +++++++++++++++++++++++ docs/roadmap.md | 1 + docs/target-app-parity.md | 1 + 5 files changed, 101 insertions(+) diff --git a/crates/engine/src/canvas/textedit.rs b/crates/engine/src/canvas/textedit.rs index 368a48c90..7191f15dd 100644 --- a/crates/engine/src/canvas/textedit.rs +++ b/crates/engine/src/canvas/textedit.rs @@ -184,14 +184,21 @@ fn edit_if(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit) -> bool let doc = ds.doc_mut(); let n = doc.get_mut(te.id)?; let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("not a text layer".into())) }; + let derived_before = crate::cmd::props::text_layer_name(&t.characters); if !f(t, &mut te) { return Ok(false); } // Any edit ends a composition (a commit replaces it with the final text). te.preedit = None; let len = t.char_count(); + let derived_after = crate::cmd::props::text_layer_name(&t.characters); te.caret = te.caret.min(len); te.anchor = te.anchor.min(len); + // The layer's name follows the characters while it is the name they derive, so the Layers + // panel keeps up with what the canvas shows; a name the author typed is left alone. + if crate::cmd::props::text_name_is_derived(&n.name, derived_before.as_ref()) { + n.name = derived_after.unwrap_or_default(); + } if n.source.is_some() { n.overrides.insert("characters".into()); } diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 36e33dd79..41cd260da 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -41,6 +41,30 @@ pub(crate) fn normalize_line_endings(s: &str) -> String { s.replace("\r\n", "\n").replace(['\r', '\u{2028}', '\u{2029}'], "\n") } +/// The name a text layer derives from its characters: the first line, at most 64 characters, or +/// None when that line is blank (a layer with no name of its own, which the Layers panel shows by +/// its kind). +/// +/// A layer whose name is this derived name is one nobody has renamed, so the editor keeps its name +/// in step as the characters are typed ([`crate::textedit`]); any other name was chosen by the +/// author and is kept. Commands never rename, so a layer named for its role keeps that name as a +/// field key even when `characters` replace text that reads like it. +pub(crate) fn text_layer_name(characters: &str) -> Option { + let first: String = characters.lines().next().unwrap_or("").chars().take(64).collect(); + (!first.trim().is_empty()).then_some(first) +} + +/// Whether `name` is one a text layer was given for its characters rather than by its author, so +/// the editor may keep it in step with them. Blank text leaves a layer named `""` when the Text +/// tool draws it and `Text` when `node.create` makes it, and both read as unnamed in the panel. +pub(crate) fn text_name_is_derived(name: &str, derived: Option<&String>) -> bool { + match derived { + Some(d) => name == d, + // `NodeKind::Text(_).label()`, which the panel falls back to for an unnamed layer. + None => name.is_empty() || name == "Text", + } +} + /// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values). pub fn snake(v: &Value) -> Value { match v { diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index 1e700c688..12a5dbfbf 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -748,3 +748,71 @@ fn a_link_is_underlined_by_default_and_underline_takes_it_off() { s.execute("text.underline", &json!({"ids": [f]})).unwrap(); assert_eq!(inner_style(&s).decoration, TextDecoration::None, "Underline on the frame reads the text inside it"); } + +fn layer_name(s: &Session, t: NodeId) -> String { + s.doc().unwrap().node(t).unwrap().name.clone() +} + +/// Select everything in the layer being edited and delete it. +fn clear_edited_text(s: &mut Session, t: NodeId) { + let len = s.doc().unwrap().node(t).unwrap().kind.text().unwrap().char_count(); + crate::textedit::set_caret(s, 0, false).unwrap(); + crate::textedit::set_caret(s, len, true).unwrap(); + crate::textedit::key(s, "Backspace", false, false, false).unwrap(); +} + +#[test] +fn typing_renames_a_text_layer_that_still_carries_the_name_its_characters_gave_it() { + // The Layers panel shows a layer's name, so a layer still carrying the name its characters + // gave it has to follow them while they are typed or the panel and the canvas disagree + // (#1747). The target app's plugin API calls this `autoRename`: "Whether updating the + // characters in the text node should update the name of the node… This is automatically reset + // to false if `name` is modified in order to allow the node to keep the new name." + let mut s = Session::new(); + + // Nobody renamed this layer, so typing in it keeps its name up to date. + let t = text(&mut s, "hello", json!({})); + assert_eq!(layer_name(&s, t), "hello", "a new layer is named after its first line"); + crate::textedit::begin(&mut s, t, Some(5), false).unwrap(); + crate::textedit::insert(&mut s, " there").unwrap(); + assert_eq!((chars(&s, t).as_str(), layer_name(&s, t).as_str()), ("hello there", "hello there"), "the name follows what is typed"); + + // A name the author typed is theirs: later typing leaves it alone. + s.execute("node.set", &json!({"id": t, "props": {"name": "Greeting"}})).unwrap(); + crate::textedit::begin(&mut s, t, Some(11), false).unwrap(); + crate::textedit::insert(&mut s, "!").unwrap(); + assert_eq!((chars(&s, t).as_str(), layer_name(&s, t).as_str()), ("hello there!", "Greeting"), "a chosen name is kept"); + + // Only the first line names the layer. + let v = text(&mut s, "first", json!({})); + crate::textedit::begin(&mut s, v, Some(5), false).unwrap(); + crate::textedit::insert(&mut s, "\nsecond").unwrap(); + assert_eq!((chars(&s, v).as_str(), layer_name(&s, v).as_str()), ("first\nsecond", "first"), "the first line alone names it"); + + // Emptied text reads as a new empty text layer does, not as the word that was deleted. + clear_edited_text(&mut s, v); + assert_eq!((chars(&s, v).as_str(), layer_name(&s, v).as_str()), ("", ""), "emptied text has no name of its own, so the panel shows its kind"); + + // The usual way a layer is made: the Text tool, a click, then typing into the empty layer. + use crate::tools::{PointerEvent, PointerKind, ToolId}; + s.set_tool(ToolId::Text); + s.pointer(&PointerEvent::at(PointerKind::Down, 300.0, 300.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 300.0, 300.0, 1.0)).unwrap(); + let drawn = s.ds().unwrap().text_edit.as_ref().unwrap().id; + assert_eq!(layer_name(&s, drawn), "", "a layer the tool drew has no name of its own yet"); + crate::textedit::insert(&mut s, "Pricing").unwrap(); + assert_eq!((chars(&s, drawn).as_str(), layer_name(&s, drawn).as_str()), ("Pricing", "Pricing"), "the first word typed names it"); +} + +#[test] +fn setting_characters_as_a_command_never_renames_the_layer() { + // Only the editor follows the characters. Layer names are field keys for `data.fill`, + // component property bindings and templates, which write `characters` into a layer named for + // its role ("Title") and look it up by that name again, so a command must leave the name as it + // is even when it reads like the text it replaces. + let mut s = Session::new(); + let t = text(&mut s, "Title", json!({})); + assert_eq!(layer_name(&s, t), "Title"); + s.execute("node.set", &json!({"id": t, "props": {"characters": "Site em 7 dias"}})).unwrap(); + assert_eq!((chars(&s, t).as_str(), layer_name(&s, t).as_str()), ("Site em 7 dias", "Title"), "the field keeps its name"); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 6bc72a278..9c9dacf37 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | patch | M4, #1747: a text layer still carrying the name its characters gave it is renamed as they are typed, so the Layers panel no longer shows the word a layer started with while the canvas shows another (`hello` typed into `hello there` read as `hello` in the panel). A name the author typed is kept, and only the editor follows the characters: commands leave the name alone, so the layer names `data.fill`, component property bindings and templates use as field keys stay put. Tests: `text_behaviour_tests::typing_renames_a_text_layer_that_still_carries_the_name_its_characters_gave_it`, `setting_characters_as_a_command_never_renames_the_layer`. Grade unchanged | | 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged | | 2026-10-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged | | 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 75a426231..5a4effee4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -648,6 +648,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-11-03 | patch | M4, #1747: a text layer still carrying the name its characters gave it is renamed as they are typed, so the Layers panel no longer shows the word a layer started with while the canvas shows another (`hello` typed into `hello there` read as `hello` in the panel). A name the author typed is kept, and only the editor follows the characters: commands leave the name alone, so the layer names `data.fill`, component property bindings and templates use as field keys stay put. Tests: `text_behaviour_tests::typing_renames_a_text_layer_that_still_carries_the_name_its_characters_gave_it`, `setting_characters_as_a_command_never_renames_the_layer`. Grade unchanged | | 2026-11-01 | patch | M4: bulleted lists draw the same bullet at every level of indentation ("Figma bullets will keep the same styling at all levels of indentation", Create bulleted and numbered lists); the deeper levels drew a white bullet and a small square, pinned as silent before. Numbered levels (1., a., i.) are unchanged. Found by a documentation audit of the text rules. Test: `tail_tests::list_numbering_by_level` (the bullets row). Numbers unchanged | | 2026-11-01 | patch | M4: typing `1)` followed by a space at the start of a plain paragraph starts a numbered list, as `1.` already did ("Enter one of the creation characters below followed by a Space: 1. 1)", Create bulleted and numbered lists). Found by a documentation audit of the text rules. Test: `text_tail_tests::typing_a_creation_character_and_a_space_starts_a_list`. Numbers unchanged | | 2026-11-01 | patch | M4: a link made with `text.link` (Cmd/Ctrl+K, Shift+Cmd/Ctrl+U) underlines the linked text unless it has a decoration of its own ("Links are styled with an underline by default. Use the keyboard shortcut to remove the underline", Add links to text); Underline (Cmd/Ctrl+U) takes the underline off and the link stays. Found by a documentation audit of the text rules. Test: `text_behaviour_tests::a_link_is_underlined_by_default_and_underline_takes_it_off`. Numbers unchanged | From d68a140755578be37ee048ba6267d532cf1fc4f5 Mon Sep 17 00:00:00 2001 From: Carbocode Date: Tue, 3 Nov 2026 10:42:02 +0100 Subject: [PATCH 16/83] M8: remove parity documentation changes --- docs/ui-parity.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 77b9ecdc2..3963e4624 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (Selection colors shares Fill and Stroke’s colour control with fractional opacity and accessible swatches; an outlined selection icon selects matching layers, and Plus and Minus share one pixel-snapped centre; the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; 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); 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); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; 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); 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); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -74,7 +74,7 @@ interaction feel not yet compared. | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). A ring at each layer's centre drags it to another place in a regular row, column or complete top-left-aligned grid: the others close up and make room around it with the gaps kept, and with Cmd/Ctrl held the two layers swap places instead, previewed live while dragging (`selection_reorder_tests`; no ring on a layer under 16 px on screen). Not covered: reordering irregular grids, and several marked layers moved together. Checked by its author on macOS and in the app on Windows (#402) | -| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart); each row shares Fill and Stroke’s colour control, and an outlined selection icon selects the layers that use its colour. Editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a colour style or colour variable of the document, and a row linked to a style or variable detaches it, keeping the colour (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). No library styles or variables from there, and no new style or variable from a row (#403) | +| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). No library styles or variables from there, and no new style or variable from a row (#403) | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes | ## Tools @@ -176,9 +176,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| -| 2026-11-02 | minor | M8, #1654: merged main while keeping linked variable names resolved by stored id; Plus and Minus share a snapped centre; shared paint controls expose their swatch values to assistive technology and retain fractional opacity through consecutive fine scrubs, with regression tests. Estimates unchanged. | -| 2026-10-30 | minor | M8: Selection colors shares Fill and Stroke’s swatch/colour/opacity control, including live opacity scrubbing with one undo step. Verified in light and dark themes; colour edits, opacity scrubbing and undo covered by panel tests. Readiness estimates unchanged. | -| 2026-10-30 | minor | M8: Selection colors uses an original outlined selection icon for selecting layers using a colour; the Plus icon’s bars share one snapped centre and equal arms at every size. Readiness estimates unchanged. | | 2026-11-01 | patch | Shortcuts: Cmd/Ctrl+Shift+Alt+= and - make the interface larger and smaller through the sizes Preferences offers, and Cmd/Ctrl+Shift+Alt+0 puts it back to the system's size ("Adjust the scale of the Figma UI [...] Reset to Default: Control ⇧Shift Alt 0; Make Larger: Control ⇧Shift Alt =; Make Smaller: Control ⇧Shift Alt -", Accessibility at Figma); the size is kept at once, as one picked in Preferences is. `pref.uiScaleUp`, `pref.uiScaleDown`, `pref.uiScaleReset`; the sizes list now lives in the engine (`UI_SCALE_SIZES`) and Preferences reads it from there. Found by a documentation audit of the shortcuts. Test: `keymap_tests::the_interface_size_keys_step_through_the_preferences_sizes`. Documented rows 130 → 133, bound 129 → 132 | | 2026-11-01 | patch | Shortcuts: Cmd/Ctrl+Alt+↑/↓ (and ←/→) rotate the selection one degree at a time, 15 with Shift ("Press ⌘Command ⌥Option ↑/ ↓ on Mac or Control Alt ↑/↓ on Windows to rotate an object one degree at a time", Accessibility at Figma); they resized by the nudge before, which Cmd/Ctrl+arrows without Alt still do. Down and right turn clockwise, up and left back (the documentation is silent on the direction). In the shortcuts panel's Transform tab, over the control channel's `ui.key` too. Found by a documentation audit of the shortcuts. Test: `shortcut_keys_tests::cmd_alt_arrows_rotate_a_degree_and_fifteen_with_shift`. Documented rows 129 → 130, bound 128 → 129 | | 2026-11-01 | patch | Shortcuts: the Layers panel opens with Ctrl+1 as well as Alt+1 on Windows and Linux ("Mac: Option 1 Windows: Ctrl 1" in Select layers and objects; "Windows: Alt 1" in Explore the navigation bar and left sidebar). macOS keeps Option+1. Found by a documentation audit of the shortcuts (every chord driven over the control channel). Tests: `keymap_tests::the_layers_panel_answers_both_of_its_documented_windows_keys`, the parity row. Counts unchanged | From 35684eaa028e636d264073a666ef11881620fa0e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 06:09:08 -0500 Subject: [PATCH 17/83] fix(format): index kiwi field lookups and bound schema and empty-array growth (#1896) A .fig message looked up each tag's field with a linear scan of its definition, so a wide schema and many tags cost their product. Schema decoding capped fields per definition but not in total, and inflated the schema chunk under the 512 MiB message cap. Empty non-byte arrays spent nothing from the decoded-value budget while still growing the output. Field ids (and enum values) are now found through a per-definition map built on first use, keeping first-match semantics. A schema holds at most 200 000 fields together and its chunk may inflate to 16 MiB. Every array field spends one value for itself before its elements. --- crates/format/src/fig.rs | 26 ++++++++++------ crates/format/src/fig/kiwi.rs | 34 +++++++++++++++++---- crates/format/src/fig/tests.rs | 54 ++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 14 deletions(-) diff --git a/crates/format/src/fig.rs b/crates/format/src/fig.rs index 55a5d7c34..7c81d0141 100644 --- a/crates/format/src/fig.rs +++ b/crates/format/src/fig.rs @@ -72,6 +72,9 @@ use kiwi::{Kv, Schema}; /// Largest `canvas.fig` member read, and largest decompressed chunk. const MAX_CANVAS: u64 = 256 * 1024 * 1024; const MAX_CHUNK: u64 = 512 * 1024 * 1024; +/// Largest decompressed schema chunk: Figma's schema unpacks to well under 1 MB, so a schema that +/// inflates past this is refused before its definitions are decoded. +const MAX_SCHEMA: u64 = 16 * 1024 * 1024; /// Largest image member read. const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES; /// Total decompressed ZIP image bytes read per import: the native image budget (`IMAGE_BUDGET`) on @@ -324,7 +327,7 @@ fn decode_canvas(canvas: &[u8]) -> Result<(Schema, Vec), FormatError> { let (Some(schema), Some(message)) = (it.next(), it.next()) else { return Err(corrupt("canvas.fig needs a schema and a message chunk")); }; - let schema = Schema::decode(&inflate(schema)?).map_err(|e| corrupt(e.to_string()))?; + let schema = Schema::decode(&inflate_within(schema, MAX_SCHEMA)?).map_err(|e| corrupt(e.to_string()))?; Ok((schema, inflate(message)?)) } @@ -347,18 +350,23 @@ fn chunks(canvas: &[u8]) -> Result, FormatError> { /// Decompress a chunk: zstd (by its magic number), else raw deflate, else zlib. fn inflate(data: &[u8]) -> Result, FormatError> { - fn capped(r: impl Read) -> Option> { + inflate_within(data, MAX_CHUNK) +} + +/// [`inflate`] refusing a chunk that unpacks to more than `max` bytes. +fn inflate_within(data: &[u8], max: u64) -> Result, FormatError> { + let capped = |r: &mut dyn Read| -> Option> { let mut out = Vec::new(); - r.take(MAX_CHUNK + 1).read_to_end(&mut out).ok()?; - (out.len() as u64 <= MAX_CHUNK).then_some(out) - } + r.take(max.saturating_add(1)).read_to_end(&mut out).ok()?; + (out.len() as u64 <= max).then_some(out) + }; if data.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) { let mut src = data; - let dec = ruzstd::decoding::StreamingDecoder::new(&mut src).map_err(|e| corrupt(format!("zstd: {e}")))?; - return capped(dec).ok_or_else(|| corrupt("damaged or oversized zstd chunk")); + let mut dec = ruzstd::decoding::StreamingDecoder::new(&mut src).map_err(|e| corrupt(format!("zstd: {e}")))?; + return capped(&mut dec).ok_or_else(|| corrupt("damaged or oversized zstd chunk")); } - capped(flate2::read::DeflateDecoder::new(data)) - .or_else(|| capped(flate2::read::ZlibDecoder::new(data))) + capped(&mut flate2::read::DeflateDecoder::new(data)) + .or_else(|| capped(&mut flate2::read::ZlibDecoder::new(data))) .ok_or_else(|| corrupt("damaged or oversized deflate chunk")) } diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index d34b3774d..909dad87c 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -6,6 +6,7 @@ //! Everything here reads untrusted bytes: reads are bounds-checked, nesting is capped, array //! counts can't exceed the bytes left, and the total number of decoded values is capped. +use std::collections::HashMap; use std::fmt; /// Deepest struct/message nesting decoded. @@ -32,6 +33,9 @@ const MAX_VALUES: usize = 32 << 20; /// Most definitions in a schema, and fields in one definition. const MAX_DEFS: usize = 10_000; const MAX_FIELDS: usize = 10_000; +/// Most fields in a schema, every definition together: Figma's own schema has a few thousand, and +/// without a total a schema of [`MAX_DEFS`] wide definitions could hold 100 million field records. +const MAX_SCHEMA_FIELDS: usize = 200_000; #[derive(Debug, PartialEq, Eq)] pub struct KiwiError(pub String); @@ -252,6 +256,7 @@ impl Schema { } let mut defs = Vec::with_capacity(count); let mut raw_types = Vec::new(); + let mut total_fields = 0usize; for _ in 0..count { let name = r.string()?; let kind = match r.byte()? { @@ -261,7 +266,8 @@ impl Schema { k => return err(format!("schema: unknown definition kind {k}")), }; let n = r.var_uint()? as usize; - if n > MAX_FIELDS || n > r.left() { + total_fields = total_fields.saturating_add(n); + if n > MAX_FIELDS || n > r.left() || total_fields > MAX_SCHEMA_FIELDS { return err("schema: too many fields"); } let mut fields = Vec::with_capacity(n); @@ -308,7 +314,7 @@ impl Schema { /// [`Self::decode_message`] within `budget` values (see [`value_budget`]). pub fn decode_message_within<'s>(&'s self, root: usize, data: &[u8], budget: usize) -> Result, KiwiError> { - let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget }; + let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget, ids: vec![None; self.defs.len()] }; d.def(root, 0) } } @@ -322,6 +328,10 @@ struct Decoder<'s, 'b> { schema: &'s Schema, r: Reader<'b>, budget: usize, + /// Per definition, the index of the first field with each id (message field ids, enum + /// values), built the first time the definition is decoded: a message can carry many tags + /// against a wide definition, and a scan per tag would multiply the two. + ids: Vec>>, } impl<'s> Decoder<'s, '_> { @@ -330,6 +340,19 @@ impl<'s> Decoder<'s, '_> { Ok(()) } + /// The first field of definition `i` with id (or enum value) `id`. + fn field_by_id(&mut self, i: usize, id: u32) -> Option<&'s Field> { + let fields = &self.schema.defs.get(i)?.fields; + let ids = self.ids.get_mut(i)?.get_or_insert_with(|| { + let mut m = HashMap::with_capacity(fields.len()); + for (j, f) in fields.iter().enumerate() { + m.entry(f.value).or_insert(j); + } + m + }); + fields.get(*ids.get(&id)?) + } + fn def(&mut self, i: usize, depth: usize) -> Result, KiwiError> { if depth > MAX_DEPTH { return err("message: nested too deeply"); @@ -340,7 +363,7 @@ impl<'s> Decoder<'s, '_> { match d.kind { Kind::Enum => { let v = self.r.var_uint()?; - Ok(d.fields.iter().find(|f| f.value == v).map_or(Kv::Uint(u64::from(v)), |f| Kv::Enum(f.name.as_str()))) + Ok(self.field_by_id(i, v).map_or(Kv::Uint(u64::from(v)), |f| Kv::Enum(f.name.as_str()))) } Kind::Struct => { let mut out = Vec::with_capacity(d.fields.len()); @@ -358,7 +381,7 @@ impl<'s> Decoder<'s, '_> { out.shrink_to_fit(); return Ok(Kv::Object(out)); } - let f = d.fields.iter().find(|f| f.value == id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?; + let f = self.field_by_id(i, id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?; out.push((f.name.as_str(), self.field(f, depth)?)); } } @@ -379,8 +402,9 @@ impl<'s> Decoder<'s, '_> { if n > self.r.left() { return err("message: array longer than the data"); } + // The field itself counts, so empty arrays can't pile up for free. + self.spend(1)?; if ty == Type::Byte { - self.spend(1)?; return Ok(Kv::Bytes(self.r.take(n)?.to_vec())); } self.spend(n)?; diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 657f2f81d..2d4a1f7d6 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -1179,6 +1179,60 @@ fn scalar_fields_count_against_the_value_budget() { assert!(sc.decode_message_within(0, &data, 1001).is_ok()); } +#[test] +fn empty_arrays_count_against_the_value_budget() { + // 1000 empty `uint[]` fields: each field is a value even with no elements, so a small budget + // can't be outgrown by piling up empty containers. + let fields = vec![Field { name: "a".into(), ty: Some(Type::Uint), array: true, value: 1 }]; + let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] }; + let mut data = [1u8, 0].repeat(1000); + data.push(0); + let e = sc.decode_message_within(0, &data, 10).unwrap_err(); + assert_eq!(e.0, "message: too many values"); + assert!(sc.decode_message_within(0, &data, 1001).is_ok()); +} + +#[test] +fn message_tags_find_their_field_by_id_in_a_wide_definition() { + // A wide definition: every tag is looked up by id, and the first field with an id wins, as + // before the lookup was indexed. + let mut fields: Vec = (1..=5000).map(|i| Field { name: format!("f{i}"), ty: Some(Type::Bool), array: false, value: i }).collect(); + fields.push(Field { name: "shadow".into(), ty: Some(Type::Bool), array: false, value: 7 }); + let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] }; + let mut w = encode::Writer::default(); + for _ in 0..20_000 { + w.var_uint(5000); + w.byte(1); + } + w.var_uint(7); + w.byte(0); + w.var_uint(0); + let Kv::Object(out) = sc.decode_message(0, &w.0).unwrap() else { panic!() }; + assert_eq!(out.len(), 20_001); + assert!(out.iter().take(20_000).all(|(n, v)| *n == "f5000" && *v == Kv::Bool(true))); + assert_eq!(out.last().map(|(n, _)| *n), Some("f7")); +} + +#[test] +fn a_schema_is_refused_past_its_total_field_count() { + // 25 definitions of 10 000 fields each: every one is within the per-definition cap, but + // together they pass the schema's total. + let mut w = encode::Writer::default(); + w.var_uint(25); + for d in 0..25 { + w.string(&format!("D{d}")); + w.byte(0); + w.var_uint(10_000); + for i in 0..10_000u32 { + w.string(""); + w.var_int(0); + w.byte(0); + w.var_uint(i); + } + } + assert_eq!(Schema::decode(&w.0).unwrap_err().0, "schema: too many fields"); +} + #[test] fn hostile_input_is_refused() { // A message that contains itself, nested past the depth cap. From 53ebf1f39c51361282ca55e18c83fbffed2f9a34 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 14:40:54 -0500 Subject: [PATCH 18/83] fix(pathops): sanitize a brush's width profile once before sampling (#1948) --- crates/pathops/src/brush.rs | 4 +++- crates/pathops/src/brush_tests.rs | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/pathops/src/brush.rs b/crates/pathops/src/brush.rs index bfd7f9212..b5875d4a2 100644 --- a/crates/pathops/src/brush.rs +++ b/crates/pathops/src/brush.rs @@ -131,7 +131,9 @@ pub fn outline(path: &BezPath, width: f64, props: &StrokeProps, brush: &Brush) - let p = preset(&brush.preset)?; let unit = shape(p.id)?; let lines = polylines(path)?; - let scale = |t: f64| width * props.profile().map_or(1.0, |pr| pr.scale_at(t)); + // Sanitized once: at most 64 points, so each sample's symmetry scan stays cheap. + let profile = props.profile().map(|pr| pr.sanitized()); + let scale = |t: f64| width * profile.as_ref().map_or(1.0, |pr| pr.scale_at(t)); let mut out = BezPath::new(); match p.kind { BrushKind::Stretch => { diff --git a/crates/pathops/src/brush_tests.rs b/crates/pathops/src/brush_tests.rs index 403935ddd..81cc55e10 100644 --- a/crates/pathops/src/brush_tests.rs +++ b/crates/pathops/src/brush_tests.rs @@ -72,3 +72,14 @@ fn a_width_profile_scales_the_brush() { let (first, last) = (parts.first().unwrap().bounding_box(), parts.last().unwrap().bounding_box()); assert!(last.width() > first.width() * 3.0, "{first:?} {last:?}"); } + +#[test] +fn an_unsanitized_profile_is_bounded_before_sampling() { + // 100k points, all past the 64-point cap and out of range: the brush reads the clamped profile. + let raw = WidthProfile { points: (0..100_000).map(|i| figcraft_doc::WidthPoint::new(f64::from(i), 1e300)).collect() }; + let b = brush("taper"); + let mut p = props(1.0, &b); + p.width_profile = Some(raw); + let out = outline(&line(50.0), 1.0, &p, &b).unwrap(); + assert!(out.bounding_box().height() <= 2.0 * WidthProfile::MAX_SCALE); +} From cfb29a4a9617b75f9072188c15ad1e24307850f8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 17:58:09 -0500 Subject: [PATCH 19/83] perf(text): compute each glyph's cluster end once per run (#1963) --- crates/text/src/layout.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f2c587206..dd728916b 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1698,11 +1698,22 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe } let (script_scale, script_shift) = synth.map_or((1.0, 0.0), |(_, up)| if up { face.superscript } else { face.subscript }); let n = raw.len(); + // Cluster end: the next larger cluster value, else the segment end. A glyph in the same + // cluster as the next one shares its end, so many glyphs of one cluster aren't rescanned. + let mut cends = vec![0usize; n]; + for gi in (0..n).rev() { + let Some(&(_, cl, ..)) = raw.get(gi) else { continue }; + let cl = cl as usize; + let shared = raw.get(gi + 1).filter(|r| r.1 as usize == cl).and_then(|_| cends.get(gi + 1).copied()); + let cend = shared.unwrap_or_else(|| raw.iter().skip(gi + 1).map(|r| r.1 as usize).find(|c| *c > cl).unwrap_or(end).max(cl + 1)); + if let Some(slot) = cends.get_mut(gi) { + *slot = cend; + } + } for gi in 0..n { let Some(&(gid, cl, xa, xo, yo)) = raw.get(gi) else { continue }; let cl = cl as usize; - // Cluster end: the next larger cluster value, else the segment end. - let cend = raw.iter().skip(gi + 1).map(|r| r.1 as usize).find(|c| *c > cl).unwrap_or(end).max(cl + 1); + let cend = cends.get(gi).copied().unwrap_or(cl + 1); let last_in_cluster = raw.get(gi + 1).is_none_or(|r| r.1 as usize != cl); let ch = chars.get(cl).copied().unwrap_or(' '); let scale = if small_at(cl) { SMALL_CAP_SCALE } else { 1.0 } * script_scale; From fb4d0b50352e97debc98b212ba6e2795a6c3d4b6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 17:58:27 -0500 Subject: [PATCH 20/83] perf(doc,engine): rebuild a widget by removing its children in one pass (#1967) --- crates/doc/src/lib.rs | 13 +++++++++++++ crates/doc/src/tests.rs | 14 ++++++++++++++ crates/engine/src/extensions/widgets.rs | 4 +--- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 89eb97269..0181ffd21 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -574,6 +574,19 @@ impl Document { Ok(()) } + /// Remove every child of `id` with its subtree, in one pass over `id`'s child list (removing + /// them one by one would search that list once per child). + pub fn remove_children(&mut self, id: NodeId) -> DocResult<()> { + let kids = std::mem::take(&mut self.get_mut(id)?.children); + for c in kids { + for d in self.subtree(c) { + self.nodes.remove(&d); + self.dirty.touch(d); + } + } + Ok(()) + } + /// Whether sections may go in `parent`: a page or another section (never a frame or a group). pub fn takes_sections(&self, parent: NodeId) -> bool { self.node(parent).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })) diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a0e3e17b0..a5c8e8e7a 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -846,3 +846,17 @@ fn the_colour_walker_visits_calculation_literals() { let ColorOperand::Literal { literal } = from else { panic!("literal operand") }; assert_eq!(literal.a, 0.5, "the mix's stored literal was converted like every other colour"); } + +#[test] +fn remove_children_removes_every_subtree_and_keeps_the_parent() { + let mut doc = Document::new("T"); + let page = doc.first_page().unwrap(); + let frame = doc.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + let inner = doc.create(frame, NodeKind::Frame, "Inner", 0.0, 0.0, 10.0, 10.0).unwrap(); + let leaf = doc.create(inner, NodeKind::Rectangle, "Leaf", 0.0, 0.0, 5.0, 5.0).unwrap(); + let other = doc.create(page, NodeKind::Rectangle, "Other", 0.0, 0.0, 5.0, 5.0).unwrap(); + doc.remove_children(frame).unwrap(); + assert!(doc.contains(frame) && doc.contains(other)); + assert!(!doc.contains(inner) && !doc.contains(leaf)); + assert!(doc.get(frame).unwrap().children.is_empty()); +} diff --git a/crates/engine/src/extensions/widgets.rs b/crates/engine/src/extensions/widgets.rs index 6386ceab4..498fbeb8a 100644 --- a/crates/engine/src/extensions/widgets.rs +++ b/crates/engine/src/extensions/widgets.rs @@ -243,9 +243,7 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<( if !is_widget(n) || n.plugin_data.get(PLUGIN).map(String::as_str) != Some(plugin) { return Err(bad(cmd, "the layer is no longer this plugin's widget")); } - for c in n.children.clone() { - doc.remove(c)?; - } + doc.remove_children(w)?; crate::cmd::props::apply(cmd, doc, w, &props(&r.root, true))?; let n = doc.get_mut(w)?; n.locked = false; From 3265cbca404644c70fb08035536882960d9f15c3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 17:58:39 -0500 Subject: [PATCH 21/83] perf(engine): look up a component's asset root once per usage report, and skip mode migration without instances (#1966) --- crates/engine/src/assets/components.rs | 4 ++++ crates/engine/src/assets/library_server.rs | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/assets/components.rs b/crates/engine/src/assets/components.rs index cee9b0bb2..7ddb22427 100644 --- a/crates/engine/src/assets/components.rs +++ b/crates/engine/src/assets/components.rs @@ -147,6 +147,10 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool { /// main layer's, for files from before `modes` was an override group. Copies follow their main /// layer's modes, so a layer on other modes chose them itself. pub fn mark_layer_modes(doc: &mut Document) { + // Without an instance nothing can be inside one: skip the per-layer ancestry lookups. + if !doc.nodes().any(|n| matches!(n.kind, NodeKind::Instance { .. })) { + return; + } let chose: Vec = doc .nodes() .filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes)) diff --git a/crates/engine/src/assets/library_server.rs b/crates/engine/src/assets/library_server.rs index 47d15a954..81889be5d 100644 --- a/crates/engine/src/assets/library_server.rs +++ b/crates/engine/src/assets/library_server.rs @@ -79,10 +79,13 @@ pub fn usage(doc: &Document, library: &str) -> Usage { let records: std::collections::BTreeMap = doc.libraries.components.iter().filter(|r| r.library == library).map(|r| (r.root, r.key)).collect(); if !records.is_empty() { + // One component's asset root is looked up once, however many instances use it. + let mut roots = std::collections::BTreeMap::>::new(); for n in doc.nodes() { if let NodeKind::Instance { component, .. } = &n.kind && store.is_none_or(|st| doc.page_of(n.id) != Some(st)) - && let Some(key) = crate::library::asset_root(doc, *component).and_then(|r| records.get(&r)) + && let Some(key) = + roots.entry(*component).or_insert_with(|| crate::library::asset_root(doc, *component)).and_then(|r| records.get(&r)) { let c = counts.entry(*key).or_default(); *c = c.saturating_add(1); From 4bb35d5266aae4dfb156cd85561c4d81198cddd9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 17:58:51 -0500 Subject: [PATCH 22/83] perf(collab): check a replaced list's entries against sets, not a scan per entry (#1962) --- crates/collab/src/change.rs | 11 +++++++++++ crates/collab/src/server.rs | 14 ++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index bcc09ad86..442376f37 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -333,6 +333,17 @@ impl Change { } impl Entry { + /// The synced list this entry belongs to (`None` for entries kept in maps). + pub(crate) fn family(&self) -> Option { + match self { + Entry::Style(_) => Some(Family::Styles), + Entry::Comment(_) => Some(Family::Comments), + Entry::Collection(_) => Some(Family::Collections), + Entry::Variable(_) => Some(Family::Variables), + Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None, + } + } + /// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live /// session carry their creator's client id, ids from before and ids of other forms are in /// space 0. `None` for entries keyed by something else (dev snapshots by layer, images by diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index b94cb5726..d5af681b2 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque}; use figcraft_doc::{Document, NodeId}; use serde::{Deserialize, Serialize}; -use crate::change::{Change, Entry, Prop, Target}; +use crate::change::{Change, Entry, Family, Prop, Target}; use crate::message::{ Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_comments, reads_signals, }; @@ -557,6 +557,7 @@ impl Server { fn check_entries(&self, from: u32, changes: &[Change], after: &Document) -> Result, Error> { let before = self.state.document(); let mut touched: BTreeSet = BTreeSet::new(); + let mut listed: BTreeMap, BTreeSet)> = BTreeMap::new(); for c in changes { match c { Change::Put { entry, .. } | Change::Remove { entry } if entry.id_space().is_some() => { @@ -565,8 +566,10 @@ impl Server { // A whole list replaced (the fallback for lists whose ids aren't unique). Change::SetDoc { prop, .. } => { if let Some(Ok(family)) = prop.list() { - touched.extend(docprops::entries(before, family)); - touched.extend(docprops::entries(after, family)); + let (b, a) = (docprops::entries(before, family), docprops::entries(after, family)); + touched.extend(b.iter().chain(&a).cloned()); + // Looked up in sets below: a linear search per entry would be quadratic. + listed.entry(family).or_insert_with(|| (b.into_iter().collect(), a.into_iter().collect())); } } _ => {} @@ -574,7 +577,10 @@ impl Server { } let mut out = Vec::new(); for entry in touched { - let (was, now) = (docprops::has(before, &entry), docprops::has(after, &entry)); + let (was, now) = match entry.family().and_then(|f| listed.get(&f)) { + Some((b, a)) => (b.contains(&entry), a.contains(&entry)), + None => (docprops::has(before, &entry), docprops::has(after, &entry)), + }; if was == now { continue; } From 2dc7bed2a0fd4c516a2edfded323efcc0478ea90 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 3 Nov 2026 21:08:19 -0500 Subject: [PATCH 23/83] M12: connectors attached to a side start 4 pt outside the border (#1916) A connector end attached to a side was routed from the middle of that side, so the stroke's centre line started on the border and half its width plus the round cap lay on the fill, and several connectors leaving one side piled up into a knot sticking into the card. FigJam starts the line a few pixels outside the border and connectors leaving one side run together along the side's normal before they fan out (the issue's screenshots). `board_route::END_GAP` (4 pt): an end attached to a side (`RouteEnd::Box`) starts that far outside it along the side's normal; an end placed by hand on the object (`RouteEnd::On`) keeps its exact point; a free end is unchanged. The pinned route coordinates in the engine, format and UI tests move with it; the overlay's attach points stay on the border. Docs: the Connectors rows, status line and history of boards-parity.md. Co-Authored-By: Claude Fable 5.1 --- crates/doc/src/board_route.rs | 15 ++++-- crates/doc/src/board_route_tests.rs | 49 +++++++++++++++++++ crates/doc/src/lib.rs | 4 +- crates/engine/src/board_connector_tests.rs | 26 +++++----- crates/engine/src/board_reshape_tests.rs | 12 ++--- crates/engine/src/board_tests.rs | 22 +++++---- crates/engine/src/board_tools_tests.rs | 14 +++--- crates/engine/src/boards/board/tools.rs | 12 +++-- crates/engine/src/canvas/snap.rs | 18 +++++-- crates/engine/src/connector_anywhere_tests.rs | 2 +- crates/format/src/fig/tail_tests.rs | 6 +-- crates/ui-egui/src/board_ui_tests.rs | 2 +- docs/boards-parity.md | 7 +-- 13 files changed, 136 insertions(+), 53 deletions(-) create mode 100644 crates/doc/src/board_route_tests.rs diff --git a/crates/doc/src/board_route.rs b/crates/doc/src/board_route.rs index 1aa45c810..7949674d9 100644 --- a/crates/doc/src/board_route.rs +++ b/crates/doc/src/board_route.rs @@ -28,9 +28,15 @@ impl RouteEnd { /// How far an elbow connector going around the objects it joins keeps from them. const ROOM: f64 = 24.0; -/// The points a connector runs through from `from` to `to`: from the middle of the side of each -/// box facing the other end (or the side it names), straight, in right angles, or (curved) its two -/// ends with the curve's two control points between them. `None` when a point isn't finite. +/// How far outside the border a connector attached to a side starts and ends (#1916): the stroke's +/// round cap never lies on the fill, and several connectors leaving one side run together along the +/// side's normal for this much before they part, as FigJam's do. +pub const END_GAP: f64 = 4.0; + +/// The points a connector runs through from `from` to `to`: from [`END_GAP`] outside the middle of +/// the side of each box facing the other end (or the side it names), straight, in right angles, or +/// (curved) its two ends with the curve's two control points between them. `None` when a point isn't +/// finite. pub fn route_points(from: RouteEnd, to: RouteEnd, routing: Routing) -> Option> { let (p0, s0) = attach(from, to.centre()); let (p1, s1) = attach(to, from.centre()); @@ -169,8 +175,9 @@ fn attach(e: RouteEnd, other: Point) -> (Point, Option) { RouteEnd::Point(p) => (p, None), RouteEnd::Box(b, side) => { let side = named_or_facing(b, side, other); - (side.point(b), Some(side)) + (side.point(b) + side.normal() * END_GAP, Some(side)) } + // Placed by hand: the point is the user's, gap or not. RouteEnd::On(b, side, p) => (p, Some(named_or_facing(b, side, other))), } } diff --git a/crates/doc/src/board_route_tests.rs b/crates/doc/src/board_route_tests.rs new file mode 100644 index 000000000..a1e972cba --- /dev/null +++ b/crates/doc/src/board_route_tests.rs @@ -0,0 +1,49 @@ +//! Connector routes (`board_route`): where an end attached to a side starts (#1916). + +use figcraft_geom::{Point, Rect}; + +use crate::board::{Routing, Side}; +use crate::board_route::{END_GAP, RouteEnd, route_points}; + +fn shape(x: f64, y: f64) -> Rect { + Rect::new(x, y, x + 100.0, y + 60.0) +} + +/// An end attached to a side starts `END_GAP` outside the middle of that side, not on the border, +/// so the stroke never lies on the fill (#1916: FigJam starts the line a few pixels outside). +#[test] +fn an_attached_end_starts_outside_the_border() { + let (a, b) = (shape(0.0, 0.0), shape(300.0, 0.0)); + for routing in [Routing::Straight, Routing::Elbow, Routing::Curved] { + let pts = route_points(RouteEnd::Box(a, Side::Auto), RouteEnd::Box(b, Side::Auto), routing).unwrap(); + assert_eq!(pts.first().copied(), Some(Point::new(100.0 + END_GAP, 30.0)), "{routing:?}: leaves the right side"); + assert_eq!(pts.last().copied(), Some(Point::new(300.0 - END_GAP, 30.0)), "{routing:?}: arrives at the left side"); + } + // A named side, and the gap along its own normal. + let pts = route_points(RouteEnd::Box(a, Side::Bottom), RouteEnd::Point(Point::new(50.0, 200.0)), Routing::Straight).unwrap(); + assert_eq!(pts.first().copied(), Some(Point::new(50.0, 60.0 + END_GAP))); +} + +/// Several connectors leaving one side run together along its normal before they part: an elbow's +/// and a curve's first run leave straight out of the side. +#[test] +fn connectors_from_one_side_leave_together() { + let a = shape(0.0, 0.0); + let (up, down) = (RouteEnd::Box(shape(300.0, -200.0), Side::Auto), RouteEnd::Box(shape(300.0, 200.0), Side::Auto)); + for routing in [Routing::Elbow, Routing::Curved] { + let p = route_points(RouteEnd::Box(a, Side::Right), up, routing).unwrap(); + let q = route_points(RouteEnd::Box(a, Side::Right), down, routing).unwrap(); + assert_eq!(p[0], q[0], "{routing:?}: the same start"); + assert_eq!(p[1].y, p[0].y, "{routing:?}: out along the side's normal first"); + assert_eq!(q[1].y, q[0].y, "{routing:?}: out along the side's normal first"); + } +} + +/// A point placed by hand on the object (`RouteEnd::On`) is the user's: no gap is added. +#[test] +fn a_hand_placed_end_keeps_its_point() { + let a = shape(0.0, 0.0); + let on = Point::new(80.0, 60.0); + let pts = route_points(RouteEnd::On(a, Side::Bottom, on), RouteEnd::Point(Point::new(80.0, 200.0)), Routing::Straight).unwrap(); + assert_eq!(pts.first().copied(), Some(on)); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 0181ffd21..ff6fc3b0f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -27,7 +27,7 @@ pub mod vector_faces; pub mod vector_regions; pub use board::*; -pub use board_route::{RouteEnd, attached_sides, path_handles, reshaped, route_along, route_points}; +pub use board_route::{END_GAP, RouteEnd, attached_sides, path_handles, reshaped, route_along, route_points}; pub use colors::*; pub use comments::*; pub use facilitation::*; @@ -1299,6 +1299,8 @@ fn remap_action(a: &mut proto::Action, map: &BTreeMap) { #[cfg(test)] mod blur_tests; #[cfg(test)] +mod board_route_tests; +#[cfg(test)] mod board_shape_tests; #[cfg(test)] mod boolean_hit_tests; diff --git a/crates/engine/src/board_connector_tests.rs b/crates/engine/src/board_connector_tests.rs index da5b3b924..7b6f2dc0a 100644 --- a/crates/engine/src/board_connector_tests.rs +++ b/crates/engine/src/board_connector_tests.rs @@ -2,7 +2,7 @@ //! command and a drag of the selected connector's end), curved connectors, and elbows that go //! around the objects they join. -use figcraft_doc::{BoardItem, End, NodeKind, Routing, Side}; +use figcraft_doc::{BoardItem, END_GAP, End, NodeKind, Routing, Side}; use figcraft_geom::{Point, Rect}; use serde_json::json; @@ -27,7 +27,7 @@ fn reattach_moves_one_end_and_undoes() { s.execute("board.reattach", &json!({"id": k, "end": "to", "target": c, "side": "top"})).unwrap(); assert_eq!(connector(&s, k).to, End::Node { node: c, side: Side::Top, at: None }); assert_eq!(connector(&s, k).from.node(), Some(a)); - assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, 0.0)), "arrives on c's top: {:?}", drawn(&s, k)); + assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, -END_GAP)), "arrives END_GAP above c's top: {:?}", drawn(&s, k)); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(connector(&s, k).to.node(), Some(b), "one undo step"); // Off into the canvas: the end stays there, free. The selection is the default connector. @@ -107,9 +107,9 @@ fn curved_connectors_leave_and_arrive_along_the_sides() { let NodeKind::Vector { network } = &n.kind else { panic!("a vector") }; assert_eq!((network.vertices.len(), network.segments.len()), (2, 1)); assert!(!network.segments[0].is_straight(), "one curve"); - assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]); + assert_eq!(drawn(&s, k), vec![Point::new(104.0, 50.0), Point::new(296.0, 50.0)]); // Side to side in a line: the curve's control points lie on that line, so its box is flat. - assert!(n.height.abs() < 1e-9 && (n.width - 200.0).abs() < 1e-9, "{} × {}", n.width, n.height); + assert!(n.height.abs() < 1e-9 && (n.width - 192.0).abs() < 1e-9, "{} × {}", n.width, n.height); // From a's bottom to c's top: out downwards and in from above; the box holds the whole curve. let k2 = id( &s.execute("board.connector", &json!({"from": a, "to": c, "fromSide": "bottom", "toSide": "top", "style": "curved", "label": "x"})).unwrap(), @@ -146,18 +146,18 @@ fn elbows_go_around_the_objects_they_join() { let pts = drawn(&s, k2); let (ra, rb) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(300.0, 0.0, 400.0, 100.0)); assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}"); - assert_eq!(pts.first().copied(), Some(Point::new(0.0, 50.0))); - assert_eq!(pts.last().copied(), Some(Point::new(400.0, 50.0))); + assert_eq!(pts.first().copied(), Some(Point::new(-END_GAP, 50.0))); + assert_eq!(pts.last().copied(), Some(Point::new(400.0 + END_GAP, 50.0))); assert_eq!(pts.len(), 6, "out, over, down and in: {pts:?}"); assert!(pts.iter().all(|p| p.y <= 50.0), "over the top, the nearer way round: {pts:?}"); assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles only"); // Facing each other, the short way stays. - assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]); + assert_eq!(drawn(&s, k), vec![Point::new(104.0, 50.0), Point::new(296.0, 50.0)]); // An end at the bottom of b leaving towards a above it: around b's side and up. s.execute("board.reattach", &json!({"id": k2, "end": "to", "target": b, "side": "bottom"})).unwrap(); let pts = drawn(&s, k2); assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}"); - assert_eq!(pts.last().copied(), Some(Point::new(350.0, 100.0))); + assert_eq!(pts.last().copied(), Some(Point::new(350.0, 100.0 + END_GAP))); } #[test] @@ -202,8 +202,12 @@ fn the_connector_being_drawn_is_the_one_made() { // Over b: its four attach points, the one the end is at lit, and b outlined. assert_eq!(ov.drop_target, Some(b)); let lit: Vec<[f64; 2]> = ov.attach.iter().filter(|a| a.1).map(|a| a.0).collect(); + // The lit attach point is the side's middle, on the border; the route's end sits + // END_GAP outside it along the side's normal (#1916). let end = *drawn(&s, drawing.id).last().unwrap(); - assert_eq!((ov.attach.len(), lit), (4, vec![[end.x, end.y]]), "{style}"); + assert_eq!(ov.attach.len(), 4, "{style}"); + let [lit] = lit.as_slice() else { panic!("{style}: one lit attach point, got {lit:?}") }; + assert!(((lit[0] - end.x).abs() + (lit[1] - end.y).abs() - END_GAP).abs() < 1e-9, "{style}: {lit:?} is END_GAP inside {end:?}"); } else { assert!(ov.attach.is_empty() && ov.drop_target.is_none(), "a free end"); } @@ -259,7 +263,7 @@ fn an_end_dropped_along_a_side_attaches_to_that_side() { s.pointer(&ev(PointerKind::Up, drop)).unwrap(); assert_eq!(connector(&s, k).from, End::Node { node: a, side, at: None }, "{drop:?}"); if side != Side::Auto { - assert_eq!(drawn(&s, k)[0], side.point(bounds), "the route leaves from that side"); + assert_eq!(drawn(&s, k)[0], side.point(bounds) + side.normal() * END_GAP, "the route leaves from that side, END_GAP out"); } } } @@ -307,5 +311,5 @@ fn a_connector_end_snaps_to_the_attach_point_under_the_pointer() { (connector(&s, k).from, connector(&s, k).to), (End::Node { node: a, side: Side::Right, at: None }, End::Node { node: b, side: Side::Top, at: None }) ); - assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(350.0, 200.0)]); + assert_eq!(drawn(&s, k), vec![Point::new(104.0, 50.0), Point::new(350.0, 196.0)]); } diff --git a/crates/engine/src/board_reshape_tests.rs b/crates/engine/src/board_reshape_tests.rs index 5a73267e3..ea4572cef 100644 --- a/crates/engine/src/board_reshape_tests.rs +++ b/crates/engine/src/board_reshape_tests.rs @@ -11,8 +11,8 @@ use crate::board::{route, selected_connector_handles}; use crate::board_tests::{board, connector, drawn, id, near}; use crate::tools::{PointerEvent, PointerKind}; -const A: Point = Point::new(100.0, 50.0); -const B: Point = Point::new(300.0, 250.0); +const A: Point = Point::new(104.0, 50.0); +const B: Point = Point::new(296.0, 250.0); /// Shapes a (at the origin) and b (down and to the right), 100 × 100, joined side to side. fn joined(style: &str) -> (Session, NodeId, NodeId, NodeId) { @@ -80,11 +80,11 @@ fn a_reshaped_connector_keeps_the_sides_it_left_by() { fn the_first_and_last_runs_gain_a_run_and_stay_on_their_ends() { let (mut s, _, _, k) = joined("elbow"); reshape(&mut s, k, 0.0, 150.0, 120.0).unwrap(); - assert_eq!(drawn(&s, k), [A, pt(124.0, 50.0), pt(124.0, 120.0), pt(200.0, 120.0), pt(200.0, 250.0), B]); + assert_eq!(drawn(&s, k), [A, pt(128.0, 50.0), pt(128.0, 120.0), pt(200.0, 120.0), pt(200.0, 250.0), B]); let last = drawn(&s, k).len() - 2; reshape(&mut s, k, last as f64, 250.0, 200.0).unwrap(); let pts = drawn(&s, k); - assert_eq!(pts, [A, pt(124.0, 50.0), pt(124.0, 120.0), pt(200.0, 120.0), pt(200.0, 200.0), pt(276.0, 200.0), pt(276.0, 250.0), B]); + assert_eq!(pts, [A, pt(128.0, 50.0), pt(128.0, 120.0), pt(200.0, 120.0), pt(200.0, 200.0), pt(272.0, 200.0), pt(272.0, 250.0), B]); assert!(right_angled(&pts)); } @@ -97,8 +97,8 @@ fn a_lone_run_gains_a_run_at_each_end() { let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "elbow"})).unwrap(), "id"); assert_eq!(drawn(&s, k).len(), 2); reshape(&mut s, k, 0.0, 200.0, -40.0).unwrap(); - let (l, r) = (pt(100.0, 50.0), pt(300.0, 50.0)); - assert_eq!(drawn(&s, k), [l, pt(124.0, 50.0), pt(124.0, -40.0), pt(276.0, -40.0), pt(276.0, 50.0), r]); + let (l, r) = (pt(104.0, 50.0), pt(296.0, 50.0)); + assert_eq!(drawn(&s, k), [l, pt(128.0, 50.0), pt(128.0, -40.0), pt(272.0, -40.0), pt(272.0, 50.0), r]); } #[test] diff --git a/crates/engine/src/board_tests.rs b/crates/engine/src/board_tests.rs index 6849ac421..9e99bd1c9 100644 --- a/crates/engine/src/board_tests.rs +++ b/crates/engine/src/board_tests.rs @@ -168,7 +168,7 @@ fn connectors_follow_what_they_join_and_undo_with_it() { let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id"); let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "then"})).unwrap(), "id"); let pts = drawn(&s, c); - assert!(near(pts[0], Point::new(200.0, 100.0)) && near(pts[1], Point::new(400.0, 100.0)), "{pts:?}"); + assert!(near(pts[0], Point::new(204.0, 100.0)) && near(pts[1], Point::new(396.0, 100.0)), "{pts:?}"); assert_eq!(node(&s, c).stroke.cap_end, StrokeCap::TriangleArrow); // Its label sits at the middle. let label = crate::board::label_of(s.doc().unwrap(), c).unwrap(); @@ -178,19 +178,19 @@ fn connectors_follow_what_they_join_and_undo_with_it() { s.execute("board.setStyle", &json!({"ids": [c], "style": "elbow"})).unwrap(); s.execute("transform.move", &json!({"ids": [b], "dx": 0, "dy": 300})).unwrap(); let pts = drawn(&s, c); - assert!(near(pts[0], Point::new(200.0, 100.0)) && near(*pts.last().unwrap(), Point::new(400.0, 400.0)), "{pts:?}"); + assert!(near(pts[0], Point::new(204.0, 100.0)) && near(*pts.last().unwrap(), Point::new(396.0, 400.0)), "{pts:?}"); assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles: {pts:?}"); // Undo takes the move back and the route with it. s.execute("edit.undo", &json!({})).unwrap(); - assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0))); + assert!(near(*drawn(&s, c).last().unwrap(), Point::new(396.0, 100.0))); // A deleted end stays where it was, as a point. s.execute("edit.delete", &json!({"ids": [b]})).unwrap(); assert!(matches!(connector(&s, c).to, End::Point { .. })); - assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0))); + assert!(near(*drawn(&s, c).last().unwrap(), Point::new(396.0, 100.0))); // A connector moved on its own takes its free end along; the attached one stays. s.execute("transform.move", &json!({"ids": [c], "dx": 0, "dy": 50})).unwrap(); - assert!(matches!(connector(&s, c).to, End::Point { x, y } if x == 400.0 && y == 150.0), "{:?}", connector(&s, c)); - assert!(near(drawn(&s, c)[0], Point::new(200.0, 100.0))); + assert!(matches!(connector(&s, c).to, End::Point { x, y } if x == 396.0 && y == 150.0), "{:?}", connector(&s, c)); + assert!(near(drawn(&s, c)[0], Point::new(204.0, 100.0))); // Deleting the connector takes its label. s.execute("edit.delete", &json!({"ids": [c]})).unwrap(); assert!(!s.doc().unwrap().contains(label)); @@ -210,8 +210,8 @@ fn routes_are_the_same_for_the_same_document() { let doc = s.doc().unwrap(); let r = route(doc, &c).unwrap(); assert_eq!(r, route(&doc.clone(), &c).unwrap()); - // Down out of the bottom, across halfway, down into the point. - assert_eq!(r, vec![Point::new(80.0, 160.0), Point::new(80.0, 330.0), Point::new(300.0, 330.0), Point::new(300.0, 500.0)]); + // Down out of the bottom (END_GAP below it), across halfway, down into the point. + assert_eq!(r, vec![Point::new(80.0, 164.0), Point::new(80.0, 332.0), Point::new(300.0, 332.0), Point::new(300.0, 500.0)]); } #[test] @@ -338,7 +338,7 @@ fn boards_sync_in_live_sessions() { let doc = room.server.document(); let NodeKind::Vector { network } = &doc.node(c).unwrap().kind else { panic!("not a vector") }; let end = doc.world_transform(c) * network.vertices.last().unwrap().point(); - assert_eq!(end, Point::new(400.0, 350.0), "to the moved sticky's facing side"); + assert_eq!(end, Point::new(396.0, 350.0), "to END_GAP before the moved sticky's facing side"); assert_eq!(doc.node(a).unwrap().fills[0].solid_color(), board_color("violet")); } @@ -410,7 +410,9 @@ fn a_press_on_a_stickys_background_picks_and_drags_it() { s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 450.0, 1.0)).unwrap(); assert_eq!(s.selection(), vec![a]); assert_eq!(node(&s, a).transform.translation().y, 300.0); - assert!(near(drawn(&s, c)[0], Point::new(200.0, 400.0)), "the connector followed the drag: {:?}", drawn(&s, c)); + // Its own connector's start, END_GAP outside its edge, is no snap target for it (#1916). + assert_eq!(node(&s, a).transform.translation().x, 0.0, "not nudged sideways by its own connector"); + assert!(near(drawn(&s, c)[0], Point::new(204.0, 400.0)), "the connector followed the drag: {:?}", drawn(&s, c)); } #[test] diff --git a/crates/engine/src/board_tools_tests.rs b/crates/engine/src/board_tools_tests.rs index 6862e4219..24e6df7dd 100644 --- a/crates/engine/src/board_tools_tests.rs +++ b/crates/engine/src/board_tools_tests.rs @@ -100,18 +100,18 @@ fn routes_take_named_sides_and_free_points() { let side = |n, side| End::Node { node: n, side, at: None }; let p = |x, y| End::Point { x, y }; let pt = Point::new; - // Side to side: out, across halfway, in. - assert_eq!(elbow(side(a, Side::Right), side(b, Side::Left)), vec![pt(100.0, 50.0), pt(200.0, 50.0), pt(200.0, 350.0), pt(300.0, 350.0)]); + // Side to side: out (from END_GAP outside each side), across halfway, in. + assert_eq!(elbow(side(a, Side::Right), side(b, Side::Left)), vec![pt(104.0, 50.0), pt(200.0, 50.0), pt(200.0, 350.0), pt(296.0, 350.0)]); // A side and a top or bottom: one turn. - assert_eq!(elbow(side(a, Side::Right), side(b, Side::Top)), vec![pt(100.0, 50.0), pt(350.0, 50.0), pt(350.0, 300.0)]); - assert_eq!(elbow(side(a, Side::Bottom), side(b, Side::Left)), vec![pt(50.0, 100.0), pt(50.0, 350.0), pt(300.0, 350.0)]); + assert_eq!(elbow(side(a, Side::Right), side(b, Side::Top)), vec![pt(104.0, 50.0), pt(350.0, 50.0), pt(350.0, 296.0)]); + assert_eq!(elbow(side(a, Side::Bottom), side(b, Side::Left)), vec![pt(50.0, 104.0), pt(50.0, 350.0), pt(296.0, 350.0)]); // Two free points: the longer way first; in a line, no turns. assert_eq!(elbow(p(0.0, 0.0), p(100.0, 40.0)), vec![pt(0.0, 0.0), pt(50.0, 0.0), pt(50.0, 40.0), pt(100.0, 40.0)]); assert_eq!(elbow(p(0.0, 0.0), p(100.0, 0.0)), vec![pt(0.0, 0.0), pt(100.0, 0.0)]); // An automatic side faces the other end. let straight = route(doc, &figcraft_doc::Connector { from: side(a, Side::Auto), to: p(50.0, 500.0), routing: figcraft_doc::Routing::Straight, path: None }); - assert_eq!(straight.unwrap(), vec![pt(50.0, 100.0), pt(50.0, 500.0)]); + assert_eq!(straight.unwrap(), vec![pt(50.0, 104.0), pt(50.0, 500.0)]); assert_eq!(Side::from_id("TOP"), Some(Side::Top)); assert_eq!(Side::from_id("inside"), None); // The same place twice is refused, as are a side that doesn't exist and a point without numbers. @@ -128,10 +128,10 @@ fn a_connector_follows_a_layer_whose_section_moves_and_ignores_unrelated_edits() let a = id(&s.execute("board.sticky", &json!({"x": 50, "y": 50, "parent": sec})).unwrap(), "id"); let b = id(&s.execute("board.stamp", &json!({"x": 800, "y": 150})).unwrap(), "id"); let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight"})).unwrap(), "id"); - assert!(near(drawn(&s, c)[0], Point::new(250.0, 150.0))); + assert!(near(drawn(&s, c)[0], Point::new(254.0, 150.0))); // Only the section is edited; the sticky inside it moves with it, and so does the connector. s.execute("transform.move", &json!({"ids": [sec], "dx": 0, "dy": 100})).unwrap(); - assert!(near(drawn(&s, c)[0], Point::new(250.0, 250.0)), "{:?}", drawn(&s, c)); + assert!(near(drawn(&s, c)[0], Point::new(254.0, 250.0)), "{:?}", drawn(&s, c)); // An edit that touches neither end leaves the connector as it was. let before = s.doc().unwrap().node_arc(c).cloned().unwrap(); s.execute("board.sticky", &json!({"x": 0, "y": 900})).unwrap(); diff --git a/crates/engine/src/boards/board/tools.rs b/crates/engine/src/boards/board/tools.rs index cf4753357..71f0dcc5b 100644 --- a/crates/engine/src/boards/board/tools.rs +++ b/crates/engine/src/boards/board/tools.rs @@ -133,13 +133,19 @@ impl Session { (to.node().is_none() || to.node() != from.node()).then_some(to) } - /// The object `end` is on (if any) outlined, with its attach points; the one the end is at - /// (`at`, else its named side's) lit. + /// The object `end` is on (if any) outlined, with its attach points (the side middles, on the + /// border); the one the end is at lit: the nearest to `at` (a route's end sits `END_GAP` outside + /// its side's middle, #1916), else its named side's. fn show_attach(&mut self, end: Option, at: Option) { let attach = match end { Some(End::Node { node, side, .. }) => self.doc().ok().and_then(|d| d.world_bounds(node)).map(|b| { let on = at.or_else(|| (side != Side::Auto).then(|| side.point(b))); - SIDES.map(|s| s.point(b)).map(|p| ([p.x, p.y], on.is_some_and(|o| (o - p).hypot() < 1e-6))).to_vec() + let pts = SIDES.map(|s| s.point(b)); + let lit = on.and_then(|o| { + let near = pts.iter().enumerate().map(|(i, p)| (i, (o - *p).hypot())).filter(|(_, d)| *d <= figcraft_doc::END_GAP + 1e-6); + near.min_by(|x, y| x.1.total_cmp(&y.1)).map(|(i, _)| i) + }); + pts.iter().enumerate().map(|(i, p)| ([p.x, p.y], lit == Some(i))).collect() }), _ => None, }; diff --git a/crates/engine/src/canvas/snap.rs b/crates/engine/src/canvas/snap.rs index fab63810c..97c23fa6c 100644 --- a/crates/engine/src/canvas/snap.rs +++ b/crates/engine/src/canvas/snap.rs @@ -4,7 +4,7 @@ //! guide lines (with spacing labels) for the canvas to draw in red. Vector points also snap to //! other points (see `vectoredit::snap`); landing on one shows a marker (a zero-length guide). -use figcraft_doc::{Document, NodeId}; +use figcraft_doc::{BoardItem, Document, NodeId}; use figcraft_geom::{Affine, Point, Rect, Vec2, transform_rect_bbox}; use serde::Serialize; @@ -94,10 +94,22 @@ impl Targets { t.ys.push((y, b.x0, b.x1)); } } - // Siblings and, at the top level, all top-level layers. + // Siblings and, at the top level, all top-level layers. A board connector joined to a + // moving layer re-routes with it, as does its label: no snapping to where its own route + // starts (#1916). + let follows = |id: NodeId| match doc.node(id).and_then(|n| n.board.as_ref()) { + Some(BoardItem::Connector(k)) => [k.from.node(), k.to.node()].into_iter().flatten().any(|e| moving.contains(&e)), + _ => false, + }; if let Some(p) = doc.node(parent) { for c in &p.children { - if moving.contains(c) || !doc.node(*c).is_some_and(|n| n.visible) { + let Some(n) = doc.node(*c).filter(|n| n.visible) else { continue }; + let joined = match n.board.as_ref() { + Some(BoardItem::Connector(_)) => follows(*c), + Some(BoardItem::Label { connector }) => follows(*connector), + _ => false, + }; + if moving.contains(c) || joined { continue; } if let Some(b) = bbox(*c) { diff --git a/crates/engine/src/connector_anywhere_tests.rs b/crates/engine/src/connector_anywhere_tests.rs index 15776fb23..f979eca57 100644 --- a/crates/engine/src/connector_anywhere_tests.rs +++ b/crates/engine/src/connector_anywhere_tests.rs @@ -46,7 +46,7 @@ fn without_cmd_an_end_keeps_snapping_to_a_side() { let end = *drawn(&s, k).last().unwrap(); drag_end(&mut s, end, Point::new(650.0, 25.0), false); assert_eq!(connector(&s, k).to, End::Node { node: b, side: Side::Right, at: None }); - assert!(near(*drawn(&s, k).last().unwrap(), Point::new(700.0, 50.0))); + assert!(near(*drawn(&s, k).last().unwrap(), Point::new(700.0 + figcraft_doc::END_GAP, 50.0)), "END_GAP outside the side"); } #[test] diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 2c8cce032..8937ffa5a 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -619,7 +619,7 @@ fn other_layer_kinds_import_as_the_closest_layers() { let world = doc.world_transform(link.id); let ends: Vec<(f64, f64)> = network_of(&doc, "Link").vertices.iter().map(|v| world * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect(); - assert_eq!(ends, [(0.0, 300.0), (200.0, 300.0), (200.0, 50.0), (400.0, 50.0)]); + assert_eq!(ends, [(0.0, 300.0), (198.0, 300.0), (198.0, 50.0), (396.0, 50.0)]); assert_eq!(link.stroke.cap_end, StrokeCap::TriangleArrow); // A table: a frame. assert!(matches!(named(&doc, "Grid").kind, NodeKind::Frame)); @@ -656,9 +656,9 @@ fn connectors_without_a_saved_path_follow_their_line_style() { // Curved: one curve from A's right side to B's left, leaving and arriving level. let curve = network_of(&doc, "Curve"); let ends: Vec<_> = curve.vertices.iter().map(|v| world("Curve") * figcraft_doc::geom::Point::new(v.x, v.y)).collect(); - assert_eq!(ends.iter().map(|p| (p.x, p.y)).collect::>(), [(100.0, 650.0), (300.0, 650.0)]); + assert_eq!(ends.iter().map(|p| (p.x, p.y)).collect::>(), [(104.0, 650.0), (296.0, 650.0)], "END_GAP outside the sides"); assert_eq!(curve.segments.len(), 1); - assert_eq!((curve.segments[0].ta, curve.segments[0].tb), ((100.0, 0.0), (-100.0, 0.0)), "the curve leaves and arrives along the sides"); + assert_eq!((curve.segments[0].ta, curve.segments[0].tb), ((96.0, 0.0), (-96.0, 0.0)), "the curve leaves and arrives along the sides"); // Straight: unchanged, to the points the file gives on the layers. let line: Vec<_> = network_of(&doc, "Line").vertices.iter().map(|v| world("Line") * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect(); diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs index 554d117d9..867ba139a 100644 --- a/crates/ui-egui/src/board_ui_tests.rs +++ b/crates/ui-egui/src/board_ui_tests.rs @@ -290,7 +290,7 @@ fn a_selected_connector_shows_its_ends_as_handles() { let b = u.app.session.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap()["id"].clone(); u.app.session.execute("board.connector", &json!({"from": a, "to": b, "style": "straight"})).unwrap(); let shapes = flat(&u.frame_output(vec![]).shapes); - let ends = [u.app.to_screen(200.0, 100.0), u.app.to_screen(400.0, 100.0)]; + let ends = [u.app.to_screen(204.0, 100.0), u.app.to_screen(396.0, 100.0)]; for end in ends { let ring = shapes.iter().any(|s| matches!(s, egui::Shape::Circle(c) if (c.center - end).length() < 0.5 && c.radius == 5.0)); assert!(ring, "a handle at {end:?}"); diff --git a/docs/boards-parity.md b/docs/boards-parity.md index 7526d39aa..689dc60a6 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,6 +1,6 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-31 · **Change:** minor (a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-04 · **Change:** minor (a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill and connectors leaving one side run together before they part (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) **Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind (`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that @@ -34,10 +34,10 @@ all end with the same geometry. | Sticky options: hide the author, change size presets, sticky stacks | partial | `board.sticky {author: false}`; no size presets or stacks | | Shapes with text | done | rectangle, rounded rectangle, ellipse, diamond, triangle, speech bubble, chevron; text centred and wrapping inside the outline; shape type and text size from the contextual bar | | Shape auto-fit to its text, more shapes (cylinder, cross, star…), shape libraries | missing | | -| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed; an end dropped on the outer half of an object takes the side it is towards (a drop along a whole edge chooses that edge), one in the middle takes the facing side; re-routed when either end moves or resizes; a deleted end stays where it was | +| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed; an end dropped on the outer half of an object takes the side it is towards (a drop along a whole edge chooses that edge), one in the middle takes the facing side; re-routed when either end moves or resizes; a deleted end stays where it was; an end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), so the stroke never lies on the fill and connectors leaving one side run together along the side's normal before they part (#1916) | | Shapes with text | done | 23 outlines: rectangle, rounded rectangle, ellipse, diamond, triangle up and down, parallelogram right and left, trapezoid, pentagon, hexagon, octagon, star, plus, arrow left and right, chevron, speech bubble, and the flowchart symbols database, document, predefined process, manual input and internal storage; text centred and wrapping inside the outline; shape type and text size from the contextual bar | | Shape auto-fit to its text, the advanced shape libraries (engineering and other icon shapes) | missing | | -| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed, and within 10 px of one the end keeps that side; re-routed when either end moves or resizes; a deleted end stays where it was | +| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed, and within 10 px of one the end keeps that side; re-routed when either end moves or resizes; a deleted end stays where it was; an end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), so the stroke never lies on the fill and connectors leaving one side run together along the side's normal before they part (#1916) | | Create the next shape already connected | done | a selected sticky or shape shows a "+" beside each side; hovering one shows where the next goes, clicking makes one of the same kind, size and colour there, joined by a connector as the Connector tool makes one, and starts typing in it; one undo step (`board.addConnected {id?, side?, edit?}`, #1597). Shift+Cmd/Ctrl+Enter on a selected (or edited) shape adds the next shape on its right, joined the same way, and goes on typing; on a sticky it adds a sticky beside it without a connector, as before | | Curved connectors, elbows around objects, dragging an end to re-attach | done | `board.setStyle {style: curved}` (and the contextual bar): one curve leaving and arriving along the sides; an elbow whose short way would cross what it joins goes around their boxes, 24 units clear; a selected connector shows its two ends as handles, and dragging one onto an object attaches it there (the object is outlined while dragging), onto empty canvas leaves it free (`board.reattach {id, end, target, side?}`, one undo step) | | A connector end on any part of an object | done | holding Cmd/Ctrl while moving an end places it where the pointer is on the object, as a share of its box, so it follows moves and resizes; the line leaves by the side facing the other end. Without the key an end snaps to a side | @@ -70,6 +70,7 @@ all end with the same geometry. | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | M12, #1916: a connector end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), as FigJam's do (the issue's screenshots: the line starts a few pixels outside, with a round cap, and connectors leaving one side run together along the side's normal before they fan out), so the stroke never lies on the fill; the `.jam` importer's routes, re-attached ends, path handles and labels follow the route's points; an end placed by hand with Cmd/Ctrl held keeps its exact point. Tests: `board_route_tests` (three). Numbers unchanged. | | 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged | | 2026-10-31 | patch | M12, #1708: a connector end dropped on the outer half of a shape takes the side it is towards, instead of only within 10 px of a side's middle, so a drop anywhere along an edge chooses that edge (dragging a selected connector's end and with the connector tool, ring included); a drop in the middle of a shape still takes the facing side. | | 2026-10-31 | minor | M12, #1710: a selected elbow or curved connector shows path handles. Dragging a run moves it and a first or last run gains an extra run to stay on its end; a curve's middle goes where it is dragged (`board.reshape`, one undo step). The shape travels with what the connector joins, is saved with the file, and goes back to routing itself when the style changes or an end moves to something else. | From 7be7c054ebaa7b4bb5d0ae148607642cb714a75e Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 3 Nov 2026 21:46:34 -0500 Subject: [PATCH 24/83] M12: a connector joined to a layer inside a moving section is no snap target; the docs say which connectors share a run (#1916) Review: a section moving carries its stickies and their connectors, so those connectors and their labels are no snap targets for the section either (an end counts as carried when its node or any ancestor is moving). The parity rows, the END_GAP doc and the test's doc no longer say connectors from one side run together: elbows share their first run along the normal, curves leave tangent to it, straight ones fan out from the same point. Co-Authored-By: Claude Fable 5.1 --- crates/doc/src/board_route.rs | 4 ++-- crates/doc/src/board_route_tests.rs | 4 ++-- crates/engine/src/board_tools_tests.rs | 26 ++++++++++++++++++++++++++ crates/engine/src/canvas/snap.rs | 7 ++++--- docs/boards-parity.md | 8 ++++---- 5 files changed, 38 insertions(+), 11 deletions(-) diff --git a/crates/doc/src/board_route.rs b/crates/doc/src/board_route.rs index 7949674d9..74e143cf0 100644 --- a/crates/doc/src/board_route.rs +++ b/crates/doc/src/board_route.rs @@ -29,8 +29,8 @@ impl RouteEnd { const ROOM: f64 = 24.0; /// How far outside the border a connector attached to a side starts and ends (#1916): the stroke's -/// round cap never lies on the fill, and several connectors leaving one side run together along the -/// side's normal for this much before they part, as FigJam's do. +/// round cap never lies on the fill, and elbows leaving one side share their first run along the +/// side's normal (curves leave tangent to it), as FigJam's connectors do. pub const END_GAP: f64 = 4.0; /// The points a connector runs through from `from` to `to`: from [`END_GAP`] outside the middle of diff --git a/crates/doc/src/board_route_tests.rs b/crates/doc/src/board_route_tests.rs index a1e972cba..43530df15 100644 --- a/crates/doc/src/board_route_tests.rs +++ b/crates/doc/src/board_route_tests.rs @@ -24,8 +24,8 @@ fn an_attached_end_starts_outside_the_border() { assert_eq!(pts.first().copied(), Some(Point::new(50.0, 60.0 + END_GAP))); } -/// Several connectors leaving one side run together along its normal before they part: an elbow's -/// and a curve's first run leave straight out of the side. +/// Elbows leaving one side share their first run along its normal, and curves leave tangent to it: +/// the second point of each lies straight out of the side. #[test] fn connectors_from_one_side_leave_together() { let a = shape(0.0, 0.0); diff --git a/crates/engine/src/board_tools_tests.rs b/crates/engine/src/board_tools_tests.rs index 24e6df7dd..6ed291e13 100644 --- a/crates/engine/src/board_tools_tests.rs +++ b/crates/engine/src/board_tools_tests.rs @@ -276,3 +276,29 @@ fn a_board_tool_left_on_presses_as_move_in_a_design_file() { assert_eq!(s.tool.tool, ToolId::Move); assert_eq!(s.selection(), vec![r], "the press selected as Move does"); } + +/// #1916: a connector's start, END_GAP outside the layer it leaves, is a snap line for other layers +/// but not for that layer, nor for a section holding it (both carry the connector along), and nor +/// is the connector's label. +#[test] +fn a_connector_joined_to_a_moving_layer_or_its_section_is_no_snap_target() { + let mut s = board(); + let sec = id(&s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 300, "height": 300})).unwrap(), "id"); + let a = id(&s.execute("board.sticky", &json!({"x": 50, "y": 50, "parent": sec})).unwrap(), "id"); + let b = id(&s.execute("board.stamp", &json!({"x": 800, "y": 150})).unwrap(), "id"); + let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "to"})).unwrap(), "id"); + // A layer the connector has nothing to do with. + let d = id(&s.execute("board.sticky", &json!({"x": 0, "y": 900})).unwrap(), "id"); + let page = s.page().unwrap(); + let start = drawn(&s, c)[0].x; + let label = crate::board::label_of(s.doc().unwrap(), c).unwrap(); + let lx = s.doc().unwrap().world_bounds(label).unwrap().x0; + // A box whose left edge is 1 past a line snaps back by 1 when the line is a target. + let pull = |s: &Session, moving: &[NodeId], x: f64| { + let t = crate::snap::Targets::gather(s.doc().unwrap(), page, moving); + t.snap_rect(figcraft_geom::Rect::new(x + 1.0, 600.0, x + 9.0, 610.0), 4.0).0 + }; + assert_eq!((pull(&s, &[d], start), pull(&s, &[d], lx)), (-1.0, -1.0), "for an unrelated layer, the connector and its label snap"); + assert_eq!(pull(&s, &[b], start), 0.0, "not for the layer it arrives at"); + assert_eq!((pull(&s, &[sec], start), pull(&s, &[sec], lx)), (0.0, 0.0), "not for the section the connector leaves from"); +} diff --git a/crates/engine/src/canvas/snap.rs b/crates/engine/src/canvas/snap.rs index 97c23fa6c..cd40437d3 100644 --- a/crates/engine/src/canvas/snap.rs +++ b/crates/engine/src/canvas/snap.rs @@ -95,10 +95,11 @@ impl Targets { } } // Siblings and, at the top level, all top-level layers. A board connector joined to a - // moving layer re-routes with it, as does its label: no snapping to where its own route - // starts (#1916). + // moving layer, or to one inside it (a section carries its stickies), re-routes with it, as + // does its label: no snapping to where its own route starts (#1916). + let carried = |e: NodeId| moving.contains(&e) || doc.ancestors(e).iter().any(|a| moving.contains(a)); let follows = |id: NodeId| match doc.node(id).and_then(|n| n.board.as_ref()) { - Some(BoardItem::Connector(k)) => [k.from.node(), k.to.node()].into_iter().flatten().any(|e| moving.contains(&e)), + Some(BoardItem::Connector(k)) => [k.from.node(), k.to.node()].into_iter().flatten().any(carried), _ => false, }; if let Some(p) = doc.node(parent) { diff --git a/docs/boards-parity.md b/docs/boards-parity.md index 689dc60a6..b364c759f 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,6 +1,6 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-04 · **Change:** minor (a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill and connectors leaving one side run together before they part (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-04 · **Change:** minor (a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) **Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind (`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that @@ -34,10 +34,10 @@ all end with the same geometry. | Sticky options: hide the author, change size presets, sticky stacks | partial | `board.sticky {author: false}`; no size presets or stacks | | Shapes with text | done | rectangle, rounded rectangle, ellipse, diamond, triangle, speech bubble, chevron; text centred and wrapping inside the outline; shape type and text size from the contextual bar | | Shape auto-fit to its text, more shapes (cylinder, cross, star…), shape libraries | missing | | -| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed; an end dropped on the outer half of an object takes the side it is towards (a drop along a whole edge chooses that edge), one in the middle takes the facing side; re-routed when either end moves or resizes; a deleted end stays where it was; an end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), so the stroke never lies on the fill and connectors leaving one side run together along the side's normal before they part (#1916) | +| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed; an end dropped on the outer half of an object takes the side it is towards (a drop along a whole edge chooses that edge), one in the middle takes the facing side; re-routed when either end moves or resizes; a deleted end stays where it was; an end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), so the stroke never lies on the fill; elbows leaving one side share their first run along its normal and curves leave tangent to it (#1916) | | Shapes with text | done | 23 outlines: rectangle, rounded rectangle, ellipse, diamond, triangle up and down, parallelogram right and left, trapezoid, pentagon, hexagon, octagon, star, plus, arrow left and right, chevron, speech bubble, and the flowchart symbols database, document, predefined process, manual input and internal storage; text centred and wrapping inside the outline; shape type and text size from the contextual bar | | Shape auto-fit to its text, the advanced shape libraries (engineering and other icon shapes) | missing | | -| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed, and within 10 px of one the end keeps that side; re-routed when either end moves or resizes; a deleted end stays where it was; an end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), so the stroke never lies on the fill and connectors leaving one side run together along the side's normal before they part (#1916) | +| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed, and within 10 px of one the end keeps that side; re-routed when either end moves or resizes; a deleted end stays where it was; an end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), so the stroke never lies on the fill; elbows leaving one side share their first run along its normal and curves leave tangent to it (#1916) | | Create the next shape already connected | done | a selected sticky or shape shows a "+" beside each side; hovering one shows where the next goes, clicking makes one of the same kind, size and colour there, joined by a connector as the Connector tool makes one, and starts typing in it; one undo step (`board.addConnected {id?, side?, edit?}`, #1597). Shift+Cmd/Ctrl+Enter on a selected (or edited) shape adds the next shape on its right, joined the same way, and goes on typing; on a sticky it adds a sticky beside it without a connector, as before | | Curved connectors, elbows around objects, dragging an end to re-attach | done | `board.setStyle {style: curved}` (and the contextual bar): one curve leaving and arriving along the sides; an elbow whose short way would cross what it joins goes around their boxes, 24 units clear; a selected connector shows its two ends as handles, and dragging one onto an object attaches it there (the object is outlined while dragging), onto empty canvas leaves it free (`board.reattach {id, end, target, side?}`, one undo step) | | A connector end on any part of an object | done | holding Cmd/Ctrl while moving an end places it where the pointer is on the object, as a share of its box, so it follows moves and resizes; the line leaves by the side facing the other end. Without the key an end snaps to a side | @@ -70,7 +70,7 @@ all end with the same geometry. | Date | Change | Summary | |---|---|---| -| 2026-11-04 | minor | M12, #1916: a connector end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), as FigJam's do (the issue's screenshots: the line starts a few pixels outside, with a round cap, and connectors leaving one side run together along the side's normal before they fan out), so the stroke never lies on the fill; the `.jam` importer's routes, re-attached ends, path handles and labels follow the route's points; an end placed by hand with Cmd/Ctrl held keeps its exact point. Tests: `board_route_tests` (three). Numbers unchanged. | +| 2026-11-04 | minor | M12, #1916: a connector end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), as FigJam's do (the issue's screenshots: the line starts a few pixels outside, with a round cap), so the stroke never lies on the fill; elbows leaving one side share their first run along its normal, curves leave tangent to it and straight ones fan out from the same point; the `.jam` importer's routes, re-attached ends, path handles and labels follow the route's points; an end placed by hand with Cmd/Ctrl held keeps its exact point. Tests: `board_route_tests` (three). Numbers unchanged. | | 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged | | 2026-10-31 | patch | M12, #1708: a connector end dropped on the outer half of a shape takes the side it is towards, instead of only within 10 px of a side's middle, so a drop anywhere along an edge chooses that edge (dragging a selected connector's end and with the connector tool, ring included); a drop in the middle of a shape still takes the facing side. | | 2026-10-31 | minor | M12, #1710: a selected elbow or curved connector shows path handles. Dragging a run moves it and a first or last run gains an extra run to stay on its end; a curve's middle goes where it is dragged (`board.reshape`, one undo step). The shape travels with what the connector joins, is saved with the file, and goes back to routing itself when the style changes or an end moves to something else. | From 88851996a0932c2bc436550b2416524289044c40 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:22:03 -0500 Subject: [PATCH 25/83] fix(format): a saved file replaces its predecessor with the same permissions (#1957) --- crates/format/src/lib.rs | 5 +++++ crates/format/src/staging_tests.rs | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 3a69ca848..e2880a3ca 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -1028,6 +1028,11 @@ pub fn write_file_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Resul let tmp = std::path::PathBuf::from(name); match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { Ok(mut file) => { + // The replacement keeps the destination's permissions from the start, so a private + // file's content is never readable under default ones (the open handle still writes). + if let Ok(old) = std::fs::metadata(path) { + let _ = file.set_permissions(old.permissions()); + } if let Err(e) = file.write_all(bytes) { let _ = std::fs::remove_file(&tmp); return Err(e); diff --git a/crates/format/src/staging_tests.rs b/crates/format/src/staging_tests.rs index 1bc199e20..abf8eacdc 100644 --- a/crates/format/src/staging_tests.rs +++ b/crates/format/src/staging_tests.rs @@ -123,3 +123,17 @@ fn a_failed_write_leaves_the_destination_alone_and_cleans_up() { } let _ = std::fs::remove_dir_all(&d); } + +#[cfg(unix)] +#[test] +fn a_replaced_file_keeps_its_permissions() { + use std::os::unix::fs::PermissionsExt; + let d = dir("mode"); + let path = d.join("a.figcraft"); + std::fs::write(&path, b"old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + write_file_atomic(&path, b"new").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + let _ = std::fs::remove_dir_all(&d); +} From df07ac2d09f0e29d40170a58f70e4bf88a659790 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:23:03 -0500 Subject: [PATCH 26/83] fix(engine): never let document text close the generated HTML style element (#1955) --- crates/engine/src/dev/codegen/html.rs | 5 ++++- crates/engine/src/html_codegen_tests.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/dev/codegen/html.rs b/crates/engine/src/dev/codegen/html.rs index d1b144cae..ac23e6a5b 100644 --- a/crates/engine/src/dev/codegen/html.rs +++ b/crates/engine/src/dev/codegen/html.rs @@ -90,7 +90,10 @@ pub(super) fn html(doc: &Document, id: NodeId) -> String { let ctx = Ctx::new(doc, id); let mut out = Markup::new(&ctx, CodeLang::Html); out.node(id, None, 0, ""); - format!("{}\n\n", out.markup, out.tokens.root_rule(), out.styles) + // Document text can reach the stylesheet through any property: `<\/` reads back as `\n{css}\n", out.markup) } /// Markup being built for a layer tree: HTML (`class`), React JSX (`className`), Vue diff --git a/crates/engine/src/html_codegen_tests.rs b/crates/engine/src/html_codegen_tests.rs index cd1318ee0..ab7115554 100644 --- a/crates/engine/src/html_codegen_tests.rs +++ b/crates/engine/src/html_codegen_tests.rs @@ -163,3 +163,16 @@ fn rem_preserves_text_property_default() { } } } + +#[test] +fn the_stylesheet_never_contains_a_closing_style_tag_from_document_text() { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let t = id(s.execute("text.create", &json!({"text": "x", "x": 0, "y": 0})).unwrap()); + s.execute("node.set", &json!({"id": t, "props": {"fontFamily": "a"}})).unwrap(); + s.execute("node.reparent", &json!({"ids": [t], "parent": f})).unwrap(); + let code = code_for(s.doc().unwrap(), f, CodeLang::Html); + let (_, css) = code.split_once("").count(), 1, "{code}"); + assert!(!code.contains("