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/78] 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/78] 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/78] 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/78] 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/78] 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/78] 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/78] 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/78] 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/78] 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/78] 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/78] 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/78] 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 6cb724c588d4abcd0a30a8b695431818f2b2ce90 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 3 Nov 2026 12:03:42 +0500 Subject: [PATCH 13/78] 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 14/78] 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 15/78] 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 53ebf1f39c51361282ca55e18c83fbffed2f9a34 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 14:40:54 -0500 Subject: [PATCH 16/78] 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 17/78] 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 18/78] 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 19/78] 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 20/78] 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 88851996a0932c2bc436550b2416524289044c40 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:22:03 -0500 Subject: [PATCH 21/78] 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 22/78] 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("