From a1ac22f12f46fd6042b669373231e8a99435ab12 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 7 Nov 2026 21:23:25 -0500 Subject: [PATCH 01/17] =?UTF-8?q?Transform:=20Shift=20while=20rotating=20l?= =?UTF-8?q?ands=20on=20multiples=20of=2015=C2=B0,=20not=2015=C2=B0=20steps?= =?UTF-8?q?=20from=20the=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured in Figma: a layer at 7° (or -4.27°) Shift-rotated by its corner ends at 15°; FigCraft rounded the turn since the drag began, so a layer at 7° landed on 7 + k*15. The rotate gesture keeps the selection's shared rotation and Shift rounds base + turn; a selection with different rotations keeps rounding the turn. Test: a layer at 7° Shift-turned by 37° ends at 45°, by 3° at 15°, and a free turn is unchanged. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/canvas/tools.rs | 4 ++- crates/engine/src/canvas/tools/drag.rs | 10 +++--- crates/engine/src/canvas/tools/handles.rs | 7 ++++ .../engine/src/transform_behaviour_tests.rs | 34 +++++++++++++++++++ docs/ui-parity.md | 5 +-- 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs index a3284cd52..875a88033 100644 --- a/crates/engine/src/canvas/tools.rs +++ b/crates/engine/src/canvas/tools.rs @@ -32,7 +32,7 @@ use handles::DRAG_SLOP_PX; pub(crate) use handles::scale_factor; pub use handles::{ PENCIL_STROKE, SMALL_BOX_PX, anchor_name, anchor_of, box_sides_px, handle_at, handle_at_corners, label_at, locked_in, scale_anchor_point, - selection_box, selection_frame, + selection_box, selection_frame, shared_rotation_degrees, }; pub(crate) use handles::{SNAP_PX, handle_cursor, readable_text_fills, resized_box}; #[cfg(test)] @@ -445,6 +445,8 @@ pub(crate) enum Gesture { Rotate { center: Point, start_angle: f64, + /// The rotation (Design panel convention) the layers share when the drag began, else 0. + base: f64, ids: Vec, }, Draw { diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs index 64d48bc86..38eef540b 100644 --- a/crates/engine/src/canvas/tools/drag.rs +++ b/crates/engine/src/canvas/tools/drag.rs @@ -1,6 +1,6 @@ use super::handles::{ SNAP_PX, handle_at, in_quad, label_at, locked_in, on_corner_square, paints_below, resized_box, scale_factor, selection_box, selection_frame, - shape_hint, + shape_hint, shared_rotation_degrees, }; use super::{ Affine, ArcHandle, ArcHint, Cursor, Document, Gesture, Guide, Handle, ImageScaleMode, LayoutMode, NodeId, NodeKind, PaintKind, Point, @@ -58,7 +58,8 @@ impl Session { .moved_origin() .unwrap_or_else(|| Point::new(b.iter().map(|c| c.x).sum::() / 4.0, b.iter().map(|c| c.y).sum::() / 4.0)); self.begin_interaction("Rotate")?; - self.tool.gesture = Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), ids }; + self.tool.gesture = + Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), base: shared_rotation_degrees(&doc, &ids), ids }; return Ok(()); } let nodes: Vec<(NodeId, Affine, f64, f64)> = @@ -350,10 +351,11 @@ impl Session { } return res; } - Gesture::Rotate { center, start_angle, ids } => { + Gesture::Rotate { center, start_angle, base, ids } => { let mut a = ((ev.p() - *center).atan2() - start_angle).to_degrees(); if ev.shift { - a = (a / 15.0).round() * 15.0; + // The panel rotation is base - a; Shift lands it on a multiple of 15 degrees. + a = base - ((base - a) / 15.0).round() * 15.0; } let (c, ids) = (*center, ids.clone()); self.preview(|doc, _| { diff --git a/crates/engine/src/canvas/tools/handles.rs b/crates/engine/src/canvas/tools/handles.rs index 9fa04d9bb..75410ec75 100644 --- a/crates/engine/src/canvas/tools/handles.rs +++ b/crates/engine/src/canvas/tools/handles.rs @@ -132,6 +132,13 @@ pub fn selection_frame(doc: &Document, sel: &[NodeId]) -> Option<(Affine, f64, f Some((rot * Affine::translate((b.x0, b.y0)), b.width(), b.height())) } +/// The rotation the Design panel shows for `ids` (degrees, counter-clockwise positive) when they +/// all share one, else 0 (the handle box is page-aligned then). Axis-aligned layers give 0, which +/// is the same as 90, 180 and 270 for stepping in 15 degrees. +pub fn shared_rotation_degrees(doc: &Document, ids: &[NodeId]) -> f64 { + shared_rotation(doc, ids).map_or(0.0, |a| -a.to_degrees()) +} + /// The rotation (radians) every layer in `ids` shares, unless it's a multiple of 90 degrees (the /// page-aligned box is then the same box). Flipped layers count by their axis, not its direction. fn shared_rotation(doc: &Document, ids: &[NodeId]) -> Option { diff --git a/crates/engine/src/transform_behaviour_tests.rs b/crates/engine/src/transform_behaviour_tests.rs index e22eb0418..1ad84fff0 100644 --- a/crates/engine/src/transform_behaviour_tests.rs +++ b/crates/engine/src/transform_behaviour_tests.rs @@ -369,6 +369,40 @@ fn resizing_and_flipping_one_layer() { assert!(near(rotation(&s, t).abs(), 30.0), "Shift: the nearest 15° step: {}", rotation(&s, t)); } +/// A rotate-handle drag on a layer at `start` degrees, turning it by `turn` degrees in the Design +/// panel's convention (positive is counter-clockwise on screen); returns the rotation it ends at. +fn rotate_drag_from(start: f64, turn: f64, m: Mods) -> f64 { + let mut s = session(); + let r = rect(&mut s, 0.0, 200.0, 100.0, 100.0, None); + set(&mut s, &[r], json!({"rotation": start})); + ex(&mut s, "select.set", json!({"ids": [r]})); + // The handle sits just outside the top-left corner of the turned box; the pointer then circles + // the centre by `turn` (the page angle runs the other way round, y being down). + let world = s.doc().unwrap().world_transform(r); + let (c, corner) = (world * Point::new(50.0, 50.0), world * Point::ZERO); + let from = corner + (corner - c) * 0.15; + let a = (from - c).atan2() - turn.to_radians(); + let to = c + figcraft_geom::Vec2::from_angle(a) * 80.0; + drag(&mut s, from, to, m); + rotation(&s, r) +} + +#[test] +fn shift_rotating_lands_on_multiples_of_15_not_steps_from_the_start() { + // Measured in Figma: a layer at 7° (or -4.27°) Shift-rotated by its corner ends at 15°, so the + // steps are absolute multiples of 15°, not 15° steps from where the drag began. + let got = rotate_drag_from(7.0, 37.0, SHIFT); + assert!(near(got, 45.0), "7° turned by 37° is 44°, nearest multiple of 15 is 45°: {got}"); + // Without Shift the turn is free. + let got = rotate_drag_from(7.0, 37.0, NONE); + assert!(near(got, 44.0), "a free turn: {got}"); + // A small turn: 7° + 3° = 10°, nearest multiple of 15 is 15° (not 7° or 10°). + let got = rotate_drag_from(7.0, 3.0, SHIFT); + assert!(near(got, 15.0), "10° rounds to 15°: {got}"); + let got = rotate_drag_from(-4.27, 2.0, SHIFT); + assert!(near(got, 0.0), "-2.27° rounds to 0°: {got}"); +} + #[test] fn groups_scale_children_keeping_their_rotation_and_flip() { let mut s = session(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index aa0ef25bd..d91ee685d 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-04 · **Change:** minor (the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); 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-07 · **Change:** minor (Shift while rotating lands on absolute multiples of 15°, measured in Figma; the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); 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: @@ -61,7 +61,7 @@ interaction feel not yet compared. | Select layer menu (every layer under the click) | done | | | Enter / Shift+Enter (children / parent), Tab / Shift+Tab (siblings) | done | Tab renames the next layer while renaming | | Resize handles: Shift keeps proportions, Alt from centre, constraints-aware | done | Alt shrinks too; past an edge flips (several layers mirror); groups and mixed rotations scale each layer along its own axes, keeping rotation and flip (no skew: children turned off a right angle are approximate); Cmd/Ctrl ignores a frame's constraints (`transform_behaviour_tests`) | -| Rotate just outside a corner; Shift in 15° steps | done | rotated handle box when layers share a rotation | +| Rotate just outside a corner; Shift in 15° steps | done | rotated handle box when layers share a rotation; Shift lands the rotation on multiples of 15° (measured in Figma: a layer at 7° or -4.27° ends at 15°), not 15° steps from where the drag began; layers with different rotations step the turn from 0 | | Flip, rotate 90° | done | | | Rotation origin (Alt+R) | done | a handle at the selection's centre to drag (snaps to the box's corners, edge middles and centre), double-click puts it back, Escape or Alt+R hides it; the corner handles, the rotation field, Rotate 90°/180° and `transform.rotate` turn about it; it follows the layers and lasts as long as the selection, not saved (the documentation doesn't say; pinned) | | Enter with a shape or frame tool chosen | done | places what a click makes (100×100, lines 100 long, sections 400×300) in the middle of the view, selected (`tool.place`); from the toolbar, Enter on the chosen tool. With nothing selected, the arrows first show a crosshair in the middle of the view and then move it by the nudge amounts (`tool.crosshair`; Shift the large one), and Enter places there; Escape or a tool change takes it away ("Use the arrow keys to activate the crosshair cursor. It will appear in the middle of the viewport indicating where the new object will be placed", Accessibility at Figma; #1839) | @@ -187,6 +187,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-07 | patch | Transform: Shift while rotating lands on absolute multiples of 15°, measured in Figma (a layer at 7° or -4.27° Shift-rotated by its corner ends at 15°); FigCraft rounded the turn since the drag began, so 7° landed on 7 + k*15. The gesture keeps the selection's shared rotation and rounds base + turn; a selection with different rotations keeps rounding the turn. Test: `shift_rotating_lands_on_multiples_of_15_not_steps_from_the_start` | | 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` | | 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged | | 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. | From 6ac20c7d53057f9b16b320fadb50cb020ad7d6a8 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 07:53:14 -0500 Subject: [PATCH 02/17] M8: points' X and Y in vector edit mode show Mixed and set every selected point, as in Figma Cause: with several points selected, the Point section's X and Y showed the selection's top-left and `vector.edit.position` moved the group so that its top-left landed on the typed value. Figma shows the value the selected points share (Mixed when they differ) and sets every selected point's own coordinate. Fix: `vector.edit.position {x?, y?}` takes, per axis, a number (every selected point's own coordinate becomes it, in the parent's coordinates) or `{add}`, `{mul}`, `{div}` applied to each point's own coordinate; each point moves by its own delta with its handles, in one undo step; a zero divisor, a non-finite result, unknown keys and other junk are refused. A new `selected_parent_points` gives the panel the points in the parent's coordinates. The panel's X and Y are mixed-value fields: the shared value (compared within 1e-6) or Mixed, a typed value sets every point, and a relative edit typed into a Mixed field sends the object form. A scrub only starts from a shared value and sets it absolutely each frame inside one interaction, as the other point fields do. Verified: engine tests for two points at different x, one point, add, mul and div, curve handles, one undo, the parent's coordinates and refused junk; panel tests for Mixed, typing -200 and a relative edit on a Mixed field, and a single point's value; the engine lib tests, the vector and field panel tests, clippy and fmt. Measured in Figma on 2026-11-08. Not changed: the W and H row, other edited layers' points, and scrubbing semantics (the Mixed field cannot be scrubbed, as no start value exists). Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove, measured and briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/canvas/vectoredit.rs | 102 +++++++++++++-- .../src/canvas/vectoredit/position_tests.rs | 122 ++++++++++++++++++ crates/ui-egui/src/field_tests.rs | 4 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/vector_panel.rs | 34 ++++- crates/ui-egui/src/vector_point_xy_tests.rs | 60 +++++++++ docs/target-app-parity.md | 3 +- docs/ui-parity.md | 5 +- 8 files changed, 309 insertions(+), 23 deletions(-) create mode 100644 crates/engine/src/canvas/vectoredit/position_tests.rs create mode 100644 crates/ui-egui/src/vector_point_xy_tests.rs diff --git a/crates/engine/src/canvas/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs index 4f265e0f5..5dcd8c0ba 100644 --- a/crates/engine/src/canvas/vectoredit.rs +++ b/crates/engine/src/canvas/vectoredit.rs @@ -10,6 +10,8 @@ pub mod ops; pub(crate) mod point_snap; mod pointer; +#[cfg(test)] +mod position_tests; pub mod regions; #[cfg(test)] mod regions_tests; @@ -706,6 +708,60 @@ fn handle_list(p: &Value, key: &str) -> Option> { } } +/// One axis of `vector.edit.position`: left alone, set, or relative to each point's own value. +#[derive(Clone, Copy)] +enum AxisEdit { + Keep, + Set(f64), + Add(f64), + Mul(f64), + Div(f64), +} + +impl AxisEdit { + fn apply(self, cur: f64) -> Result { + let v = match self { + AxisEdit::Keep => cur, + AxisEdit::Set(v) => v, + AxisEdit::Add(n) => cur + n, + AxisEdit::Mul(n) => cur * n, + AxisEdit::Div(n) if n != 0.0 => cur / n, + AxisEdit::Div(_) => return Err(bad("vector.edit.position", "cannot divide by zero")), + }; + if v.is_finite() { Ok(v) } else { Err(bad("vector.edit.position", "the result is not a number")) } + } +} + +/// Parse `p[key]`: absent is `Keep`, a number is `Set`, `{add|mul|div: n}` is relative; anything else is refused. +fn axis_edit(p: &Value, key: &str) -> Result { + let junk = || bad("vector.edit.position", format!("`{key}` must be a number or {{add|mul|div: number}}")); + match get(p, key) { + None => Ok(AxisEdit::Keep), + Some(Value::Object(o)) => { + let (Some((k, v)), 1) = (o.iter().next(), o.len()) else { return Err(junk()) }; + let n = match v { + Value::Number(n) => n.as_f64().filter(|n| n.is_finite()), + _ => None, + } + .ok_or_else(junk)?; + match k.as_str() { + "add" => Ok(AxisEdit::Add(n)), + "mul" => Ok(AxisEdit::Mul(n)), + "div" if n != 0.0 => Ok(AxisEdit::Div(n)), + _ => Err(junk()), + } + } + Some(v) => f64_of(v).filter(|_| !v.is_boolean()).map(AxisEdit::Set).ok_or_else(junk), + } +} + +/// The selected points in the parent's coordinates (what the X/Y fields show). +pub fn selected_parent_points(doc: &Document, ve: &VectorEdit) -> Vec { + let Some(net) = network(doc, ve.id) else { return Vec::new() }; + let t = doc.node(ve.id).map(|n| n.transform).unwrap_or_default(); + selected_points(net, ve).into_iter().filter_map(|v| net.vertices.get(v)).map(|v| t * v.point()).collect() +} + /// Selection bounds' top-left in the parent's coordinates (what the X/Y fields show). pub fn selection_position(doc: &Document, ve: &VectorEdit) -> Option { let net = network(doc, ve.id)?; @@ -935,18 +991,40 @@ pub fn specs() -> Vec { edit_sets(s, sets, |net, v, w| ops::move_vertices(net, v, local_offset(w, d)))?; Ok(json!({"ok": true})) }), - spec!("vector.edit.position", "Point position", "{x?, y?} (the selected points' top-left, in the parent's coordinates)", true, |s, p| { - let ve = editing(s)?; - let doc = s.doc()?; - let cur = selection_position(doc, &ve).ok_or_else(|| bad("vector.edit.position", "select points"))?; - let target = Point::new(num_or(p, "x", cur.x), num_or(p, "y", cur.y)); - let t = doc.node(ve.id).map(|n| n.transform).unwrap_or_default(); - let inv = t.inverse(); - let d = inv * target - inv * cur; - let v = network(doc, ve.id).map(|n| selected_points(n, &ve)).unwrap_or_default(); - edit(s.doc_mut()?, ve.id, |net| ops::move_vertices(net, &v, d))?; - Ok(json!({"ok": true})) - }), + spec!( + "vector.edit.position", + "Point position", + "{x?, y?}: each a number (every selected point's own coordinate becomes it) or {add: n}, {mul: n} or {div: n} (applied to each selected point's own coordinate); in the parent's coordinates", + true, + |s, p| { + let ve = editing(s)?; + let doc = s.doc()?; + let (ax, ay) = (axis_edit(p, "x")?, axis_edit(p, "y")?); + let t = doc.node(ve.id).map(|n| n.transform).unwrap_or_default(); + let inv = t.inverse(); + let net = network(doc, ve.id).ok_or_else(|| bad("vector.edit.position", "select points"))?; + let mut moves: Vec<(usize, Vec2)> = Vec::new(); + for i in selected_points(net, &ve) { + let Some(v) = net.vertices.get(i) else { continue }; + let cur = t * v.point(); + let target = Point::new(ax.apply(cur.x)?, ay.apply(cur.y)?); + let d = inv * target - v.point(); + if !(d.x.is_finite() && d.y.is_finite()) { + return Err(bad("vector.edit.position", "the result is not a number")); + } + moves.push((i, d)); + } + if moves.is_empty() { + return Err(bad("vector.edit.position", "select points")); + } + edit(s.doc_mut()?, ve.id, |net| { + for (i, d) in &moves { + ops::move_vertices(net, &[*i], *d); + } + })?; + Ok(json!({"ok": true})) + } + ), spec!("vector.edit.setHandle", "Set handle", "{segment, start (bool), x, y (local), independent?}", true, |s, p| { let ve = editing(s)?; let seg = index("vector.edit.setHandle", p, "segment")?; diff --git a/crates/engine/src/canvas/vectoredit/position_tests.rs b/crates/engine/src/canvas/vectoredit/position_tests.rs new file mode 100644 index 000000000..d092227e0 --- /dev/null +++ b/crates/engine/src/canvas/vectoredit/position_tests.rs @@ -0,0 +1,122 @@ +//! `vector.edit.position`: with several points, a number sets every selected point's own +//! coordinate, and `{add}`, `{mul}`, `{div}` apply to each point's own. + +use serde_json::json; + +use super::tests::{ex, net, rect}; +use super::*; +use crate::Session; + +/// Every point in the parent's coordinates (the layer re-fits its box after an edit, so its own +/// coordinates shift). +fn xs(s: &Session, id: NodeId) -> Vec<(f64, f64)> { + let t = s.doc().unwrap().node(id).unwrap().transform; + net(s, id).vertices.iter().map(|v| t * v.point()).map(|p| (p.x, p.y)).collect() +} + +fn parent_xy(s: &Session) -> Vec<(f64, f64)> { + let ve = s.ds().unwrap().vector_edit.clone().unwrap(); + selected_parent_points(s.doc().unwrap(), &ve).iter().map(|p| (p.x, p.y)).collect() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +#[test] +fn a_number_sets_every_selected_points_own_coordinate() { + let mut s = Session::new(); + let id = rect(&mut s); + let before = xs(&s, id); + ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); + // The rectangle's points 0 and 1 differ in x; both end at -200 and keep their y. + assert!(!near(before[0].0, before[1].0)); + ex(&mut s, "vector.edit.position", json!({"x": -200})); + let after = xs(&s, id); + assert!(near(after[0].0, -200.0) && near(after[1].0, -200.0), "{after:?}"); + assert!(near(after[0].1, before[0].1) && near(after[1].1, before[1].1)); + assert_eq!(after[2], before[2]); + // One undo step restores. + assert!(s.undo().unwrap()); + assert_eq!(xs(&s, id), before); +} + +#[test] +fn one_point_sets_its_own_coordinates() { + let mut s = Session::new(); + let id = rect(&mut s); + let before = xs(&s, id); + ex(&mut s, "vector.edit.select", json!({"vertices": [2]})); + ex(&mut s, "vector.edit.position", json!({"x": 7, "y": 9})); + let after = xs(&s, id); + assert!(near(after[2].0, 7.0) && near(after[2].1, 9.0)); + assert_eq!(after[0], before[0]); +} + +#[test] +fn relative_edits_apply_to_each_points_own_value() { + let mut s = Session::new(); + let id = rect(&mut s); + let before = xs(&s, id); + ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); + ex(&mut s, "vector.edit.position", json!({"x": {"add": 10}})); + let a = xs(&s, id); + assert!(near(a[0].0, before[0].0 + 10.0) && near(a[1].0, before[1].0 + 10.0), "{a:?}"); + ex(&mut s, "vector.edit.position", json!({"y": {"mul": 2}})); + let b = xs(&s, id); + assert!(near(b[0].1, a[0].1 * 2.0) && near(b[1].1, a[1].1 * 2.0), "{b:?}"); + ex(&mut s, "vector.edit.position", json!({"x": {"div": 2}})); + let c = xs(&s, id); + assert!(near(c[0].0, a[0].0 / 2.0) && near(c[1].0, a[1].0 / 2.0), "{c:?}"); +} + +#[test] +fn a_point_keeps_its_handles_relative_to_it() { + let mut s = Session::new(); + let id = rect(&mut s); + ex(&mut s, "vector.edit.select", json!({"vertices": [1]})); + ex(&mut s, "vector.edit.smooth", json!({})); + let before = net(&s, id); + assert!(before.segments.iter().any(|g| g.ta != (0.0, 0.0) || g.tb != (0.0, 0.0))); + ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); + ex(&mut s, "vector.edit.position", json!({"x": {"add": 25}})); + let after = net(&s, id); + let tangents = |n: &VectorNetwork| n.segments.iter().map(|g| (g.ta, g.tb)).collect::>(); + assert_eq!(tangents(&before), tangents(&after)); +} + +#[test] +fn the_values_are_in_the_parents_coordinates() { + let mut s = Session::new(); + let r = ex(&mut s, "shape.rectangle", json!({"x": 40, "y": 30, "width": 100, "height": 100})); + let id = crate::params::id_of(&r["id"]).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"id": id})); + ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); + let before = parent_xy(&s); + assert!(near(before[0].0, 40.0), "{before:?}"); + ex(&mut s, "vector.edit.position", json!({"x": 10})); + let after = parent_xy(&s); + assert!(near(after[0].0, 10.0) && near(after[1].0, 10.0), "{after:?}"); + assert!(near(after[0].1, before[0].1)); +} + +#[test] +fn junk_is_refused_and_changes_nothing() { + let mut s = Session::new(); + let id = rect(&mut s); + let before = xs(&s, id); + ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); + for junk in [ + json!({"x": {"div": 0}}), + json!({"x": {"pow": 2}}), + json!({"x": "a"}), + json!({"x": {"add": 1, "mul": 2}}), + json!({"y": {"add": "x"}}), + json!({"x": {}}), + json!({"x": [1]}), + json!({"x": {"mul": 1e308}}), + ] { + assert!(s.execute("vector.edit.position", &junk).is_err(), "{junk}"); + } + assert_eq!(xs(&s, id), before); +} diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index e3bdad1dd..2b1ead4aa 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -189,7 +189,7 @@ fn setup() -> (Ui, NodeId, NodeId) { /// 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) { +pub(crate) 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; @@ -252,7 +252,7 @@ fn click(u: &mut Ui, p: Pos2) { } /// Click into field `name`, type `text` over what it shows and press `end`. -fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) { +pub(crate) fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) { let (p, _) = field(u, name); click(u, p); u.frame(vec![Event::Text(text.into())]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 11c96da74..5532b6dc4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -279,6 +279,8 @@ mod vector_keys_tests; #[cfg(test)] mod vector_panel_tests; #[cfg(test)] +mod vector_point_xy_tests; +#[cfg(test)] mod vector_tail_ui_tests; #[cfg(test)] mod vote_history_ui_tests; diff --git a/crates/ui-egui/src/panels/vector_panel.rs b/crates/ui-egui/src/panels/vector_panel.rs index 07dda3ff4..3b91c9ffc 100644 --- a/crates/ui-egui/src/panels/vector_panel.rs +++ b/crates/ui-egui/src/panels/vector_panel.rs @@ -12,7 +12,7 @@ use crate::FigApp; use crate::i18n::tr; use crate::icons::Icon; use crate::right::{CAP_ART_W, cap_art, join_label, menu_item, menu_item_with, menu_popup, row}; -use crate::widgets::{self, FieldEdit, Lead}; +use crate::widgets::{self, Edit, FieldEdit, Lead, Rel}; /// Run a point command from a field: typed values are one step, scrubs one step when released. fn point_field(app: &mut FigApp, cmd: &str, edit: Option, params: impl Fn(f64) -> Value) { @@ -31,6 +31,23 @@ fn point_field(app: &mut FigApp, cmd: &str, edit: Option, params: imp } } +/// A point position field's edit: a value (typed or scrubbed) sets every selected point's own +/// coordinate; a relative edit of a Mixed field (`+10`, `*2`) applies to each point's own. +fn point_edit(app: &mut FigApp, axis: &'static str, edit: Option) { + match edit { + Some(Edit::All(e)) => point_field(app, "vector.edit.position", Some(e), |v| json!({ axis: v })), + Some(Edit::Each(rel)) => { + let op = match rel { + Rel::Add(n) => json!({"add": n}), + Rel::Mul(n) => json!({"mul": n}), + Rel::Div(n) => json!({"div": n}), + }; + app.run("vector.edit.position", json!({ axis: op })); + } + None => {} + } +} + /// The Variable width tool's selected width point: where it is along the path and its width. fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { widgets::section_header(ui, tr("Width point"), &[], false); @@ -88,14 +105,19 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { return; } let col = widgets::col_width(ui.available_width()); - let pos = vectoredit::selection_position(doc, ve); let round = |v: f64| (v * 100.0).round() / 100.0; + let parent_pts = vectoredit::selected_parent_points(doc, ve); + // The value the selected points share (within 1e-6), or none: Mixed. + let shared = |axis: fn(&figcraft_geom::Point) -> f64| { + let first = parent_pts.first().map(axis)?; + parent_pts.iter().all(|p| (axis(p) - first).abs() < 1e-6).then(|| round(first)) + }; widgets::sublabels(ui, tr("Position"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::unique("vpt_x"), Lead::Text("X"), pos.map(|p| round(p.x)), "", col, 1.0); - point_field(app, "vector.edit.position", e, |v| json!({"x": v})); - let e = widgets::number_field(ui, Id::unique("vpt_y"), Lead::Text("Y"), pos.map(|p| round(p.y)), "", col, 1.0); - point_field(app, "vector.edit.position", e, |v| json!({"y": v})); + let e = widgets::number_field_mixed(ui, Id::unique("vpt_x"), Lead::Text("X"), shared(|p| p.x), "", col, 1.0); + point_edit(app, "x", e); + let e = widgets::number_field_mixed(ui, Id::unique("vpt_y"), Lead::Text("Y"), shared(|p| p.y), "", col, 1.0); + point_edit(app, "y", e); }); if let Some(b) = vectoredit::transform::point_box(doc, ve).filter(|b| b.points.len() > 1) { widgets::sublabels(ui, tr("Dimensions"), None); diff --git a/crates/ui-egui/src/vector_point_xy_tests.rs b/crates/ui-egui/src/vector_point_xy_tests.rs new file mode 100644 index 000000000..8ab7bce4d --- /dev/null +++ b/crates/ui-egui/src/vector_point_xy_tests.rs @@ -0,0 +1,60 @@ +//! The vector edit panel's Point section: X and Y show the value the selected points share, or +//! "Mixed", and a typed value sets every selected point's own coordinate. + +use egui::Key; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::field_tests::{field, type_into}; +use crate::layers_drag_tests::Ui; + +fn setup() -> (Ui, NodeId) { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("vector.edit.begin", &json!({"id": id})).unwrap(); + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + u.frame(vec![]); + (u, id) +} + +fn xs(u: &Ui, id: NodeId) -> Vec<(f64, f64)> { + let doc = u.app.session.doc().unwrap(); + // In the parent's coordinates: the layer re-fits its box after an edit. + let t = doc.node(id).unwrap().transform; + figcraft_engine::vectoredit::network(doc, id).unwrap().vertices.iter().map(|v| t * v.point()).map(|p| (p.x, p.y)).collect() +} + +#[test] +fn x_reads_mixed_for_points_that_differ_and_typing_sets_every_point() { + let (mut u, id) = setup(); + u.app.session.execute("vector.edit.select", &json!({"vertices": [0, 1]})).unwrap(); + let before = xs(&u, id); + // Points 0 and 1 share their y but not their x. + assert_eq!(field(&mut u, "X").1, "Mixed"); + assert_eq!(field(&mut u, "Y").1, "0"); + type_into(&mut u, "X", "-200", Key::Enter); + let after = xs(&u, id); + assert_eq!((after[0].0, after[1].0), (-200.0, -200.0)); + assert_eq!((after[0].1, after[1].1), (before[0].1, before[1].1)); + assert_eq!(field(&mut u, "X").1, "-200", "the points share an x now"); + // A relative edit of a Mixed field goes to each point from its own value. + u.app.session.execute("vector.edit.select", &json!({"vertices": [1, 2]})).unwrap(); + assert_eq!(field(&mut u, "X").1, "Mixed"); + let b = xs(&u, id); + type_into(&mut u, "X", "+10", Key::Enter); + let a = xs(&u, id); + assert_eq!((a[1].0, a[2].0), (b[1].0 + 10.0, b[2].0 + 10.0)); +} + +#[test] +fn one_point_shows_its_own_value() { + let (mut u, id) = setup(); + u.app.session.execute("vector.edit.select", &json!({"vertices": [2]})).unwrap(); + let p = xs(&u, id)[2]; + assert_eq!(field(&mut u, "X").1, format!("{}", p.0)); + assert_eq!(field(&mut u, "Y").1, format!("{}", p.1)); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 031521f51..16098efdc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (the Point section's X and Y in vector edit mode show Mixed and set every selected point, as in Figma; justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -652,6 +652,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | M8: measured in Figma on 2026-11-08, vector edit mode's Point section: X and Y show the value the selected points share, or Mixed when they differ, and a typed value sets every selected point's own coordinate (it moved the group's top-left before); a relative edit (`+10`, `*2`, `/2`) typed into a Mixed field applies to each point's own value (`vector.edit.position` takes a number or `{add}`, `{mul}`, `{div}` per axis); the W and H row and other edited layers' points are unchanged. | | 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged | | 2026-11-04 | patch | M4, #1970: justified Chinese, Japanese and Korean text, written without spaces between words, aligns its right edge: the room on each line but a paragraph's last goes between its characters, each a word of its own as for line breaking ("Justify aligns the first word in each line of text to the left-most edge, and the last to the right-most edge"), wherever a line could break there, so none opens before a full stop or closing bracket, nor after an opening one. Latin text widens its spaces as before; mixed text widens both. A gap is reordered with the line's runs as a space of no width would be, so one beside a Hebrew word in Chinese text, or beside a Chinese word within that Hebrew, opens beside the word, not inside it. `figcraft_text::breaks::justify_between`, beside `break_between`, says where. Evidence: `tests::justified_ideographs_spread_the_room_between_characters` (fails on main). | | 2026-11-04 | patch | M8, #1951 (#1506 again): a path broken open by deleting a point (Delete, not Shift+Delete) is filled closed by a straight line across the gap, as an open path's fill is; joining its two ends again (the pen onto the other end, Connect, ⌘J) left the loop without the new segment, so once the points were rounded the fill and the selection outline ran straight across while the stroke followed the segment and its arcs. Joining now closes the loop through the new segment ("Click to close the path."; "You can use the Paint tool to add fills to any closed regions of a vector network."): `ops::connect` puts it into the loop whose segments are the chain between the ends, at the gap and in the loop's own order, so a hole keeps its winding whichever end was clicked first; a loop listed out of walking order is left as it was. Evidence: `vectoredit::tests::a_rounded_chamfer_made_other_ways_fills_along_its_arcs` (fails on main: the loop kept 4 of 5 segments), `a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs` (Shift+Delete, with and without a region fill), `doc::tests::a_chamfered_rectangles_rounded_fill_is_cut_at_every_point` (fill, region path and stroke agree for every loop order and segment direction of a chamfered rectangle). | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index aa0ef25bd..6c0fd92e6 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-04 · **Change:** minor (the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); 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-08 · **Change:** minor (the Point section's X and Y in vector edit mode show Mixed and set every selected point, as in Figma; the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); 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: @@ -92,7 +92,7 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); Enter or a double-click ends the path (at the double-click's point, added once) and edits the vector with the Move tool, a double-click with the idle pen edits the layer under it, and in edit mode the pen's Enter or double-click takes the Move tool (#1430, `pen_vector_edit_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875). Not yet: areas of other layers edited at once | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875). Not yet: areas of other layers edited at once; the Point section's X and Y show the shared value or Mixed and set every selected point (measured in Figma on 2026-11-08) | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104); the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with their own icons, two offset squares with the result filled at reduced alpha (#2003) | | Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | @@ -187,6 +187,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | M8: measured in Figma on 2026-11-08, vector edit mode's Point section: X and Y show the value the selected points share, or Mixed when they differ, and a typed value sets every selected point's own coordinate (it moved the group's top-left before); a relative edit (`+10`, `*2`, `/2`) typed into a Mixed field applies to each point's own value (`vector.edit.position` takes a number or `{add}`, `{mul}`, `{div}` per axis); the W and H row and other edited layers' points are unchanged. | | 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` | | 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged | | 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. | From 767ebb67cc8a966110d43a258964d7eb8aa41b74 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 07:53:27 -0500 Subject: [PATCH 03/17] M8: store the new vector point tests with LF line endings Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove, measured and briefed; Sonnet 5.5 wrote at medium --- .../src/canvas/vectoredit/position_tests.rs | 244 +++++++++--------- crates/ui-egui/src/vector_point_xy_tests.rs | 120 ++++----- 2 files changed, 182 insertions(+), 182 deletions(-) diff --git a/crates/engine/src/canvas/vectoredit/position_tests.rs b/crates/engine/src/canvas/vectoredit/position_tests.rs index d092227e0..f64da34d5 100644 --- a/crates/engine/src/canvas/vectoredit/position_tests.rs +++ b/crates/engine/src/canvas/vectoredit/position_tests.rs @@ -1,122 +1,122 @@ -//! `vector.edit.position`: with several points, a number sets every selected point's own -//! coordinate, and `{add}`, `{mul}`, `{div}` apply to each point's own. - -use serde_json::json; - -use super::tests::{ex, net, rect}; -use super::*; -use crate::Session; - -/// Every point in the parent's coordinates (the layer re-fits its box after an edit, so its own -/// coordinates shift). -fn xs(s: &Session, id: NodeId) -> Vec<(f64, f64)> { - let t = s.doc().unwrap().node(id).unwrap().transform; - net(s, id).vertices.iter().map(|v| t * v.point()).map(|p| (p.x, p.y)).collect() -} - -fn parent_xy(s: &Session) -> Vec<(f64, f64)> { - let ve = s.ds().unwrap().vector_edit.clone().unwrap(); - selected_parent_points(s.doc().unwrap(), &ve).iter().map(|p| (p.x, p.y)).collect() -} - -fn near(a: f64, b: f64) -> bool { - (a - b).abs() < 1e-6 -} - -#[test] -fn a_number_sets_every_selected_points_own_coordinate() { - let mut s = Session::new(); - let id = rect(&mut s); - let before = xs(&s, id); - ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); - // The rectangle's points 0 and 1 differ in x; both end at -200 and keep their y. - assert!(!near(before[0].0, before[1].0)); - ex(&mut s, "vector.edit.position", json!({"x": -200})); - let after = xs(&s, id); - assert!(near(after[0].0, -200.0) && near(after[1].0, -200.0), "{after:?}"); - assert!(near(after[0].1, before[0].1) && near(after[1].1, before[1].1)); - assert_eq!(after[2], before[2]); - // One undo step restores. - assert!(s.undo().unwrap()); - assert_eq!(xs(&s, id), before); -} - -#[test] -fn one_point_sets_its_own_coordinates() { - let mut s = Session::new(); - let id = rect(&mut s); - let before = xs(&s, id); - ex(&mut s, "vector.edit.select", json!({"vertices": [2]})); - ex(&mut s, "vector.edit.position", json!({"x": 7, "y": 9})); - let after = xs(&s, id); - assert!(near(after[2].0, 7.0) && near(after[2].1, 9.0)); - assert_eq!(after[0], before[0]); -} - -#[test] -fn relative_edits_apply_to_each_points_own_value() { - let mut s = Session::new(); - let id = rect(&mut s); - let before = xs(&s, id); - ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); - ex(&mut s, "vector.edit.position", json!({"x": {"add": 10}})); - let a = xs(&s, id); - assert!(near(a[0].0, before[0].0 + 10.0) && near(a[1].0, before[1].0 + 10.0), "{a:?}"); - ex(&mut s, "vector.edit.position", json!({"y": {"mul": 2}})); - let b = xs(&s, id); - assert!(near(b[0].1, a[0].1 * 2.0) && near(b[1].1, a[1].1 * 2.0), "{b:?}"); - ex(&mut s, "vector.edit.position", json!({"x": {"div": 2}})); - let c = xs(&s, id); - assert!(near(c[0].0, a[0].0 / 2.0) && near(c[1].0, a[1].0 / 2.0), "{c:?}"); -} - -#[test] -fn a_point_keeps_its_handles_relative_to_it() { - let mut s = Session::new(); - let id = rect(&mut s); - ex(&mut s, "vector.edit.select", json!({"vertices": [1]})); - ex(&mut s, "vector.edit.smooth", json!({})); - let before = net(&s, id); - assert!(before.segments.iter().any(|g| g.ta != (0.0, 0.0) || g.tb != (0.0, 0.0))); - ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); - ex(&mut s, "vector.edit.position", json!({"x": {"add": 25}})); - let after = net(&s, id); - let tangents = |n: &VectorNetwork| n.segments.iter().map(|g| (g.ta, g.tb)).collect::>(); - assert_eq!(tangents(&before), tangents(&after)); -} - -#[test] -fn the_values_are_in_the_parents_coordinates() { - let mut s = Session::new(); - let r = ex(&mut s, "shape.rectangle", json!({"x": 40, "y": 30, "width": 100, "height": 100})); - let id = crate::params::id_of(&r["id"]).unwrap(); - ex(&mut s, "vector.edit.begin", json!({"id": id})); - ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); - let before = parent_xy(&s); - assert!(near(before[0].0, 40.0), "{before:?}"); - ex(&mut s, "vector.edit.position", json!({"x": 10})); - let after = parent_xy(&s); - assert!(near(after[0].0, 10.0) && near(after[1].0, 10.0), "{after:?}"); - assert!(near(after[0].1, before[0].1)); -} - -#[test] -fn junk_is_refused_and_changes_nothing() { - let mut s = Session::new(); - let id = rect(&mut s); - let before = xs(&s, id); - ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); - for junk in [ - json!({"x": {"div": 0}}), - json!({"x": {"pow": 2}}), - json!({"x": "a"}), - json!({"x": {"add": 1, "mul": 2}}), - json!({"y": {"add": "x"}}), - json!({"x": {}}), - json!({"x": [1]}), - json!({"x": {"mul": 1e308}}), - ] { - assert!(s.execute("vector.edit.position", &junk).is_err(), "{junk}"); - } - assert_eq!(xs(&s, id), before); -} +//! `vector.edit.position`: with several points, a number sets every selected point's own +//! coordinate, and `{add}`, `{mul}`, `{div}` apply to each point's own. + +use serde_json::json; + +use super::tests::{ex, net, rect}; +use super::*; +use crate::Session; + +/// Every point in the parent's coordinates (the layer re-fits its box after an edit, so its own +/// coordinates shift). +fn xs(s: &Session, id: NodeId) -> Vec<(f64, f64)> { + let t = s.doc().unwrap().node(id).unwrap().transform; + net(s, id).vertices.iter().map(|v| t * v.point()).map(|p| (p.x, p.y)).collect() +} + +fn parent_xy(s: &Session) -> Vec<(f64, f64)> { + let ve = s.ds().unwrap().vector_edit.clone().unwrap(); + selected_parent_points(s.doc().unwrap(), &ve).iter().map(|p| (p.x, p.y)).collect() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +#[test] +fn a_number_sets_every_selected_points_own_coordinate() { + let mut s = Session::new(); + let id = rect(&mut s); + let before = xs(&s, id); + ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); + // The rectangle's points 0 and 1 differ in x; both end at -200 and keep their y. + assert!(!near(before[0].0, before[1].0)); + ex(&mut s, "vector.edit.position", json!({"x": -200})); + let after = xs(&s, id); + assert!(near(after[0].0, -200.0) && near(after[1].0, -200.0), "{after:?}"); + assert!(near(after[0].1, before[0].1) && near(after[1].1, before[1].1)); + assert_eq!(after[2], before[2]); + // One undo step restores. + assert!(s.undo().unwrap()); + assert_eq!(xs(&s, id), before); +} + +#[test] +fn one_point_sets_its_own_coordinates() { + let mut s = Session::new(); + let id = rect(&mut s); + let before = xs(&s, id); + ex(&mut s, "vector.edit.select", json!({"vertices": [2]})); + ex(&mut s, "vector.edit.position", json!({"x": 7, "y": 9})); + let after = xs(&s, id); + assert!(near(after[2].0, 7.0) && near(after[2].1, 9.0)); + assert_eq!(after[0], before[0]); +} + +#[test] +fn relative_edits_apply_to_each_points_own_value() { + let mut s = Session::new(); + let id = rect(&mut s); + let before = xs(&s, id); + ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); + ex(&mut s, "vector.edit.position", json!({"x": {"add": 10}})); + let a = xs(&s, id); + assert!(near(a[0].0, before[0].0 + 10.0) && near(a[1].0, before[1].0 + 10.0), "{a:?}"); + ex(&mut s, "vector.edit.position", json!({"y": {"mul": 2}})); + let b = xs(&s, id); + assert!(near(b[0].1, a[0].1 * 2.0) && near(b[1].1, a[1].1 * 2.0), "{b:?}"); + ex(&mut s, "vector.edit.position", json!({"x": {"div": 2}})); + let c = xs(&s, id); + assert!(near(c[0].0, a[0].0 / 2.0) && near(c[1].0, a[1].0 / 2.0), "{c:?}"); +} + +#[test] +fn a_point_keeps_its_handles_relative_to_it() { + let mut s = Session::new(); + let id = rect(&mut s); + ex(&mut s, "vector.edit.select", json!({"vertices": [1]})); + ex(&mut s, "vector.edit.smooth", json!({})); + let before = net(&s, id); + assert!(before.segments.iter().any(|g| g.ta != (0.0, 0.0) || g.tb != (0.0, 0.0))); + ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); + ex(&mut s, "vector.edit.position", json!({"x": {"add": 25}})); + let after = net(&s, id); + let tangents = |n: &VectorNetwork| n.segments.iter().map(|g| (g.ta, g.tb)).collect::>(); + assert_eq!(tangents(&before), tangents(&after)); +} + +#[test] +fn the_values_are_in_the_parents_coordinates() { + let mut s = Session::new(); + let r = ex(&mut s, "shape.rectangle", json!({"x": 40, "y": 30, "width": 100, "height": 100})); + let id = crate::params::id_of(&r["id"]).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"id": id})); + ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); + let before = parent_xy(&s); + assert!(near(before[0].0, 40.0), "{before:?}"); + ex(&mut s, "vector.edit.position", json!({"x": 10})); + let after = parent_xy(&s); + assert!(near(after[0].0, 10.0) && near(after[1].0, 10.0), "{after:?}"); + assert!(near(after[0].1, before[0].1)); +} + +#[test] +fn junk_is_refused_and_changes_nothing() { + let mut s = Session::new(); + let id = rect(&mut s); + let before = xs(&s, id); + ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); + for junk in [ + json!({"x": {"div": 0}}), + json!({"x": {"pow": 2}}), + json!({"x": "a"}), + json!({"x": {"add": 1, "mul": 2}}), + json!({"y": {"add": "x"}}), + json!({"x": {}}), + json!({"x": [1]}), + json!({"x": {"mul": 1e308}}), + ] { + assert!(s.execute("vector.edit.position", &junk).is_err(), "{junk}"); + } + assert_eq!(xs(&s, id), before); +} diff --git a/crates/ui-egui/src/vector_point_xy_tests.rs b/crates/ui-egui/src/vector_point_xy_tests.rs index 8ab7bce4d..07acb17da 100644 --- a/crates/ui-egui/src/vector_point_xy_tests.rs +++ b/crates/ui-egui/src/vector_point_xy_tests.rs @@ -1,60 +1,60 @@ -//! The vector edit panel's Point section: X and Y show the value the selected points share, or -//! "Mixed", and a typed value sets every selected point's own coordinate. - -use egui::Key; -use figcraft_doc::NodeId; -use figcraft_engine::Session; -use serde_json::json; - -use crate::field_tests::{field, type_into}; -use crate::layers_drag_tests::Ui; - -fn setup() -> (Ui, NodeId) { - let mut s = Session::new(); - let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap(); - let id = NodeId(r["id"].as_u64().unwrap()); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - s.execute("vector.edit.begin", &json!({"id": id})).unwrap(); - let mut u = Ui::new(s); - u.ctx.enable_accesskit(); - u.frame(vec![]); - (u, id) -} - -fn xs(u: &Ui, id: NodeId) -> Vec<(f64, f64)> { - let doc = u.app.session.doc().unwrap(); - // In the parent's coordinates: the layer re-fits its box after an edit. - let t = doc.node(id).unwrap().transform; - figcraft_engine::vectoredit::network(doc, id).unwrap().vertices.iter().map(|v| t * v.point()).map(|p| (p.x, p.y)).collect() -} - -#[test] -fn x_reads_mixed_for_points_that_differ_and_typing_sets_every_point() { - let (mut u, id) = setup(); - u.app.session.execute("vector.edit.select", &json!({"vertices": [0, 1]})).unwrap(); - let before = xs(&u, id); - // Points 0 and 1 share their y but not their x. - assert_eq!(field(&mut u, "X").1, "Mixed"); - assert_eq!(field(&mut u, "Y").1, "0"); - type_into(&mut u, "X", "-200", Key::Enter); - let after = xs(&u, id); - assert_eq!((after[0].0, after[1].0), (-200.0, -200.0)); - assert_eq!((after[0].1, after[1].1), (before[0].1, before[1].1)); - assert_eq!(field(&mut u, "X").1, "-200", "the points share an x now"); - // A relative edit of a Mixed field goes to each point from its own value. - u.app.session.execute("vector.edit.select", &json!({"vertices": [1, 2]})).unwrap(); - assert_eq!(field(&mut u, "X").1, "Mixed"); - let b = xs(&u, id); - type_into(&mut u, "X", "+10", Key::Enter); - let a = xs(&u, id); - assert_eq!((a[1].0, a[2].0), (b[1].0 + 10.0, b[2].0 + 10.0)); -} - -#[test] -fn one_point_shows_its_own_value() { - let (mut u, id) = setup(); - u.app.session.execute("vector.edit.select", &json!({"vertices": [2]})).unwrap(); - let p = xs(&u, id)[2]; - assert_eq!(field(&mut u, "X").1, format!("{}", p.0)); - assert_eq!(field(&mut u, "Y").1, format!("{}", p.1)); -} +//! The vector edit panel's Point section: X and Y show the value the selected points share, or +//! "Mixed", and a typed value sets every selected point's own coordinate. + +use egui::Key; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::field_tests::{field, type_into}; +use crate::layers_drag_tests::Ui; + +fn setup() -> (Ui, NodeId) { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("vector.edit.begin", &json!({"id": id})).unwrap(); + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + u.frame(vec![]); + (u, id) +} + +fn xs(u: &Ui, id: NodeId) -> Vec<(f64, f64)> { + let doc = u.app.session.doc().unwrap(); + // In the parent's coordinates: the layer re-fits its box after an edit. + let t = doc.node(id).unwrap().transform; + figcraft_engine::vectoredit::network(doc, id).unwrap().vertices.iter().map(|v| t * v.point()).map(|p| (p.x, p.y)).collect() +} + +#[test] +fn x_reads_mixed_for_points_that_differ_and_typing_sets_every_point() { + let (mut u, id) = setup(); + u.app.session.execute("vector.edit.select", &json!({"vertices": [0, 1]})).unwrap(); + let before = xs(&u, id); + // Points 0 and 1 share their y but not their x. + assert_eq!(field(&mut u, "X").1, "Mixed"); + assert_eq!(field(&mut u, "Y").1, "0"); + type_into(&mut u, "X", "-200", Key::Enter); + let after = xs(&u, id); + assert_eq!((after[0].0, after[1].0), (-200.0, -200.0)); + assert_eq!((after[0].1, after[1].1), (before[0].1, before[1].1)); + assert_eq!(field(&mut u, "X").1, "-200", "the points share an x now"); + // A relative edit of a Mixed field goes to each point from its own value. + u.app.session.execute("vector.edit.select", &json!({"vertices": [1, 2]})).unwrap(); + assert_eq!(field(&mut u, "X").1, "Mixed"); + let b = xs(&u, id); + type_into(&mut u, "X", "+10", Key::Enter); + let a = xs(&u, id); + assert_eq!((a[1].0, a[2].0), (b[1].0 + 10.0, b[2].0 + 10.0)); +} + +#[test] +fn one_point_shows_its_own_value() { + let (mut u, id) = setup(); + u.app.session.execute("vector.edit.select", &json!({"vertices": [2]})).unwrap(); + let p = xs(&u, id)[2]; + assert_eq!(field(&mut u, "X").1, format!("{}", p.0)); + assert_eq!(field(&mut u, "Y").1, format!("{}", p.1)); +} From 8edcc02aee7993e9cb38bf1a2ac5cf41a2ab0bcf Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 07:57:54 -0500 Subject: [PATCH 04/17] M8: points moved by one offset still carry the regions' paint frames Setting each point's own coordinate moved the points one at a time, so when every point of a layer moved by the same offset (`{"x": {"add": 10}}` on all of them) `ops::move_vertices` no longer saw the whole network move and left the regions' paint frames behind. Points with the same offset now move together. Verified: `moving_every_point_by_one_offset_carries_the_regions_paint_frame` failed before this and passes; vectoredit tests 92 passed, junk sweep 49 passed, clippy and fmt clean. Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and wrote --- crates/engine/src/canvas/vectoredit.rs | 13 ++++++++-- .../src/canvas/vectoredit/position_tests.rs | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/canvas/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs index 5dcd8c0ba..47597ea92 100644 --- a/crates/engine/src/canvas/vectoredit.rs +++ b/crates/engine/src/canvas/vectoredit.rs @@ -1017,9 +1017,18 @@ pub fn specs() -> Vec { if moves.is_empty() { return Err(bad("vector.edit.position", "select points")); } + // Points with the same offset move together, so moving every point by one offset + // still carries the regions' paint frames, as a drag of all of them does. + let mut groups: Vec<(Vec2, Vec)> = Vec::new(); + for (i, d) in moves { + match groups.iter_mut().find(|(g, _)| (*g - d).hypot() < 1e-9) { + Some((_, v)) => v.push(i), + None => groups.push((d, vec![i])), + } + } edit(s.doc_mut()?, ve.id, |net| { - for (i, d) in &moves { - ops::move_vertices(net, &[*i], *d); + for (d, v) in &groups { + ops::move_vertices(net, v, *d); } })?; Ok(json!({"ok": true})) diff --git a/crates/engine/src/canvas/vectoredit/position_tests.rs b/crates/engine/src/canvas/vectoredit/position_tests.rs index f64da34d5..fb6af9105 100644 --- a/crates/engine/src/canvas/vectoredit/position_tests.rs +++ b/crates/engine/src/canvas/vectoredit/position_tests.rs @@ -100,6 +100,32 @@ fn the_values_are_in_the_parents_coordinates() { assert!(near(after[0].1, before[0].1)); } +#[test] +fn moving_every_point_by_one_offset_carries_the_regions_paint_frame() { + // `{add}` on every point is a plain translation: the region's paint frame goes with the + // points, as when they are dragged together. + let mut s = Session::new(); + let id = rect(&mut s); + { + let node = s.doc_mut().unwrap().get_mut(id).unwrap(); + let NodeKind::Vector { network } = &mut node.kind else { panic!("expected a vector") }; + assert!(!network.regions.is_empty(), "the rectangle has a region"); + for region in &mut network.regions { + region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: 100.0, height: 100.0, transform: Affine::IDENTITY }); + } + } + let frame = |s: &Session| { + let world = s.doc().unwrap().world_transform(id); + world * net(s, id).regions[0].paint_space.as_ref().unwrap().transform + }; + let before = frame(&s); + let all: Vec = (0..net(&s, id).vertices.len()).collect(); + ex(&mut s, "vector.edit.select", json!({"vertices": all})); + ex(&mut s, "vector.edit.position", json!({"x": {"add": 10}})); + let (want, got) = ((Affine::translate((10.0, 0.0)) * before).as_coeffs(), frame(&s).as_coeffs()); + assert!(want.iter().zip(got).all(|(a, b)| near(*a, b)), "{got:?} vs {want:?}"); +} + #[test] fn junk_is_refused_and_changes_nothing() { let mut s = Session::new(); From 194a4a13298fdb10469ed283c56fe0f7ac74650b Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 08:31:06 -0500 Subject: [PATCH 05/17] M8: the parity row lists the points' X and Y as done, not under Not yet CodeRabbit on #2121: the Vector edit mode row in ui-parity.md put the new Point-section X/Y behaviour after "Not yet:", contradicting the row's done status and the revision entry. It now sits with the implemented features; only areas of other layers edited at once stay under Not yet. Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and wrote --- docs/ui-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6c0fd92e6..bad1cb900 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -92,7 +92,7 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); Enter or a double-click ends the path (at the double-click's point, added once) and edits the vector with the Move tool, a double-click with the idle pen edits the layer under it, and in edit mode the pen's Enter or double-click takes the Move tool (#1430, `pen_vector_edit_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875). Not yet: areas of other layers edited at once; the Point section's X and Y show the shared value or Mixed and set every selected point (measured in Figma on 2026-11-08) | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875); the Point section's X and Y show the value the selected points share, or Mixed, and a typed value sets every selected point's own coordinate (measured in Figma on 2026-11-08). Not yet: areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104); the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with their own icons, two offset squares with the result filled at reduced alpha (#2003) | | Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | From 62e7e3a41a7231b8b35561f530b94f2a43780aca Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 09:32:09 -0500 Subject: [PATCH 06/17] M8: the parity docs scope the points' X and Y to the layer being edited CodeRabbit on #2121: with points selected in several edited layers, the X and Y fields read and set only the active layer's points (`selected_parent_points` and `vector.edit.position` use the edited layer), so the status lines, the vector edit row and the revision entries now say so, and the row lists X and Y over several edited layers under Not yet. Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and wrote --- docs/target-app-parity.md | 4 ++-- docs/ui-parity.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 16098efdc..7f5f0eed1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (the Point section's X and Y in vector edit mode show Mixed and set every selected point, as in Figma; justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -652,7 +652,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| -| 2026-11-08 | minor | M8: measured in Figma on 2026-11-08, vector edit mode's Point section: X and Y show the value the selected points share, or Mixed when they differ, and a typed value sets every selected point's own coordinate (it moved the group's top-left before); a relative edit (`+10`, `*2`, `/2`) typed into a Mixed field applies to each point's own value (`vector.edit.position` takes a number or `{add}`, `{mul}`, `{div}` per axis); the W and H row and other edited layers' points are unchanged. | +| 2026-11-08 | minor | M8: measured in Figma on 2026-11-08, vector edit mode's Point section: X and Y show the value the selected points share, or Mixed when they differ, and a typed value sets every selected point's own coordinate in the layer being edited, points of other edited layers left as they are (it moved the group's top-left before); a relative edit (`+10`, `*2`, `/2`) typed into a Mixed field applies to each point's own value (`vector.edit.position` takes a number or `{add}`, `{mul}`, `{div}` per axis); the W and H row and other edited layers' points are unchanged. | | 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged | | 2026-11-04 | patch | M4, #1970: justified Chinese, Japanese and Korean text, written without spaces between words, aligns its right edge: the room on each line but a paragraph's last goes between its characters, each a word of its own as for line breaking ("Justify aligns the first word in each line of text to the left-most edge, and the last to the right-most edge"), wherever a line could break there, so none opens before a full stop or closing bracket, nor after an opening one. Latin text widens its spaces as before; mixed text widens both. A gap is reordered with the line's runs as a space of no width would be, so one beside a Hebrew word in Chinese text, or beside a Chinese word within that Hebrew, opens beside the word, not inside it. `figcraft_text::breaks::justify_between`, beside `break_between`, says where. Evidence: `tests::justified_ideographs_spread_the_room_between_characters` (fails on main). | | 2026-11-04 | patch | M8, #1951 (#1506 again): a path broken open by deleting a point (Delete, not Shift+Delete) is filled closed by a straight line across the gap, as an open path's fill is; joining its two ends again (the pen onto the other end, Connect, ⌘J) left the loop without the new segment, so once the points were rounded the fill and the selection outline ran straight across while the stroke followed the segment and its arcs. Joining now closes the loop through the new segment ("Click to close the path."; "You can use the Paint tool to add fills to any closed regions of a vector network."): `ops::connect` puts it into the loop whose segments are the chain between the ends, at the gap and in the loop's own order, so a hole keeps its winding whichever end was clicked first; a loop listed out of walking order is left as it was. Evidence: `vectoredit::tests::a_rounded_chamfer_made_other_ways_fills_along_its_arcs` (fails on main: the loop kept 4 of 5 segments), `a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs` (Shift+Delete, with and without a region fill), `doc::tests::a_chamfered_rectangles_rounded_fill_is_cut_at_every_point` (fill, region path and stroke agree for every loop order and segment direction of a chamfered rectangle). | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index bad1cb900..9b3546b93 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-08 · **Change:** minor (the Point section's X and Y in vector edit mode show Mixed and set every selected point, as in Figma; the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); 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-08 · **Change:** minor (the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); 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: @@ -92,7 +92,7 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); Enter or a double-click ends the path (at the double-click's point, added once) and edits the vector with the Move tool, a double-click with the idle pen edits the layer under it, and in edit mode the pen's Enter or double-click takes the Move tool (#1430, `pen_vector_edit_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875); the Point section's X and Y show the value the selected points share, or Mixed, and a typed value sets every selected point's own coordinate (measured in Figma on 2026-11-08). Not yet: areas of other layers edited at once | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875); the Point section's X and Y show the value the selected points share, or Mixed, and a typed value sets every selected point's own coordinate, in the layer being edited (measured in Figma on 2026-11-08). Not yet: areas of other layers edited at once; the Point section's X and Y over points of several edited layers | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104); the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with their own icons, two offset squares with the result filled at reduced alpha (#2003) | | Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | @@ -187,7 +187,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| -| 2026-11-08 | minor | M8: measured in Figma on 2026-11-08, vector edit mode's Point section: X and Y show the value the selected points share, or Mixed when they differ, and a typed value sets every selected point's own coordinate (it moved the group's top-left before); a relative edit (`+10`, `*2`, `/2`) typed into a Mixed field applies to each point's own value (`vector.edit.position` takes a number or `{add}`, `{mul}`, `{div}` per axis); the W and H row and other edited layers' points are unchanged. | +| 2026-11-08 | minor | M8: measured in Figma on 2026-11-08, vector edit mode's Point section: X and Y show the value the selected points share, or Mixed when they differ, and a typed value sets every selected point's own coordinate in the layer being edited, points of other edited layers left as they are (it moved the group's top-left before); a relative edit (`+10`, `*2`, `/2`) typed into a Mixed field applies to each point's own value (`vector.edit.position` takes a number or `{add}`, `{mul}`, `{div}` per axis); the W and H row and other edited layers' points are unchanged. | | 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` | | 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged | | 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. | From dc841f854805fabefbf4d4e102f4006c01e08ef2 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 14:44:47 -0500 Subject: [PATCH 07/17] M8: vector.edit.position moves points in one pass; refusal, empty-selection and scrub tests The handler grouped equal offsets by a linear scan per point, quadratic when every point moves by its own offset. Only a single move covering every point carries the regions' paint frames, so it now checks in one pass whether all offsets are equal: if so the points move as one, otherwise each moves by its own offset. The result is the same as before. Tests: booleans, a valid x with a refused y, and the overflow case are refused with no undo step; nothing selected is refused; a shared Y scrubs every point in one undo step and a Mixed X doesn't scrub. selection_position's doc comment names its one user, vector.edit.bounds. Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and wrote --- crates/engine/src/canvas/vectoredit.rs | 25 ++++++++++--------- .../src/canvas/vectoredit/position_tests.rs | 23 +++++++++++++++++ crates/ui-egui/src/field_tests.rs | 4 +-- crates/ui-egui/src/vector_point_xy_tests.rs | 23 +++++++++++++++-- 4 files changed, 59 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/canvas/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs index 47597ea92..db4dfc099 100644 --- a/crates/engine/src/canvas/vectoredit.rs +++ b/crates/engine/src/canvas/vectoredit.rs @@ -762,7 +762,7 @@ pub fn selected_parent_points(doc: &Document, ve: &VectorEdit) -> Vec { selected_points(net, ve).into_iter().filter_map(|v| net.vertices.get(v)).map(|v| t * v.point()).collect() } -/// Selection bounds' top-left in the parent's coordinates (what the X/Y fields show). +/// Selection bounds' top-left in the parent's coordinates (the `x` and `y` of `vector.edit.bounds`). pub fn selection_position(doc: &Document, ve: &VectorEdit) -> Option { let net = network(doc, ve.id)?; let t = doc.node(ve.id)?.transform; @@ -1017,18 +1017,19 @@ pub fn specs() -> Vec { if moves.is_empty() { return Err(bad("vector.edit.position", "select points")); } - // Points with the same offset move together, so moving every point by one offset - // still carries the regions' paint frames, as a drag of all of them does. - let mut groups: Vec<(Vec2, Vec)> = Vec::new(); - for (i, d) in moves { - match groups.iter_mut().find(|(g, _)| (*g - d).hypot() < 1e-9) { - Some((_, v)) => v.push(i), - None => groups.push((d, vec![i])), - } - } + // Points that all share one offset move as one, so moving every point of a layer + // by one offset still carries the regions' paint frames, as a drag of all of them + // does. Otherwise no one move covers every point, so each point moves alone. + let d0 = moves[0].1; + let uniform = moves.iter().all(|(_, d)| (*d - d0).hypot() < 1e-9); edit(s.doc_mut()?, ve.id, |net| { - for (d, v) in &groups { - ops::move_vertices(net, v, *d); + if uniform { + let all: Vec = moves.iter().map(|(i, _)| *i).collect(); + ops::move_vertices(net, &all, d0); + } else { + for (i, d) in &moves { + ops::move_vertices(net, &[*i], *d); + } } })?; Ok(json!({"ok": true})) diff --git a/crates/engine/src/canvas/vectoredit/position_tests.rs b/crates/engine/src/canvas/vectoredit/position_tests.rs index fb6af9105..efbddab8d 100644 --- a/crates/engine/src/canvas/vectoredit/position_tests.rs +++ b/crates/engine/src/canvas/vectoredit/position_tests.rs @@ -19,6 +19,10 @@ fn parent_xy(s: &Session) -> Vec<(f64, f64)> { selected_parent_points(s.doc().unwrap(), &ve).iter().map(|p| (p.x, p.y)).collect() } +fn undo_steps(s: &Session) -> usize { + s.ds().unwrap().history.undo.len() +} + fn near(a: f64, b: f64) -> bool { (a - b).abs() < 1e-6 } @@ -132,6 +136,7 @@ fn junk_is_refused_and_changes_nothing() { let id = rect(&mut s); let before = xs(&s, id); ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); + let steps = undo_steps(&s); for junk in [ json!({"x": {"div": 0}}), json!({"x": {"pow": 2}}), @@ -140,9 +145,27 @@ fn junk_is_refused_and_changes_nothing() { json!({"y": {"add": "x"}}), json!({"x": {}}), json!({"x": [1]}), + json!({"x": true}), + json!({"x": {"add": true}}), + // A valid x with a refused y changes neither. + json!({"x": 5, "y": {"div": 0}}), + // Point 1's x of 100 overflows; point 0's 0 alone would not. json!({"x": {"mul": 1e308}}), ] { assert!(s.execute("vector.edit.position", &junk).is_err(), "{junk}"); } assert_eq!(xs(&s, id), before); + assert_eq!(undo_steps(&s), steps, "a refused edit leaves no undo step"); +} + +#[test] +fn nothing_selected_is_refused() { + let mut s = Session::new(); + let id = rect(&mut s); + ex(&mut s, "vector.edit.select", json!({"vertices": []})); + let (before, steps) = (xs(&s, id), undo_steps(&s)); + assert!(parent_xy(&s).is_empty()); + let err = s.execute("vector.edit.position", &json!({"x": 5})).unwrap_err(); + assert!(err.to_string().contains("select points"), "{err}"); + assert_eq!((xs(&s, id), undo_steps(&s)), (before, steps)); } diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 2b1ead4aa..001187a91 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -234,7 +234,7 @@ fn geom(u: &Ui, n: NodeId) -> (f64, f64, f64, f64) { (b.x0, b.y0, b.width(), b.height()) } -fn undo_steps(u: &Ui) -> usize { +pub(crate) fn undo_steps(u: &Ui) -> usize { u.app.session.ds().unwrap().history.undo.len() } @@ -261,7 +261,7 @@ pub(crate) fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) { } /// Scrub field `name` by its label: `dx` points with `modifiers` held, then let go. -fn scrub(u: &mut Ui, name: &str, dx: f32, modifiers: Modifiers) { +pub(crate) fn scrub(u: &mut Ui, name: &str, dx: f32, modifiers: Modifiers) { let from = label(u, name); drag(u, from, dx, modifiers); } diff --git a/crates/ui-egui/src/vector_point_xy_tests.rs b/crates/ui-egui/src/vector_point_xy_tests.rs index 07acb17da..e93f62873 100644 --- a/crates/ui-egui/src/vector_point_xy_tests.rs +++ b/crates/ui-egui/src/vector_point_xy_tests.rs @@ -1,12 +1,12 @@ //! The vector edit panel's Point section: X and Y show the value the selected points share, or //! "Mixed", and a typed value sets every selected point's own coordinate. -use egui::Key; +use egui::{Key, Modifiers}; use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; -use crate::field_tests::{field, type_into}; +use crate::field_tests::{field, scrub, type_into, undo_steps}; use crate::layers_drag_tests::Ui; fn setup() -> (Ui, NodeId) { @@ -58,3 +58,22 @@ fn one_point_shows_its_own_value() { assert_eq!(field(&mut u, "X").1, format!("{}", p.0)); assert_eq!(field(&mut u, "Y").1, format!("{}", p.1)); } + +#[test] +fn a_shared_value_scrubs_every_point_in_one_step_and_mixed_does_not_scrub() { + let (mut u, id) = setup(); + // Points 0 and 1 share their y: a scrub moves both to the same new y, as one undo step. + u.app.session.execute("vector.edit.select", &json!({"vertices": [0, 1]})).unwrap(); + let (before, steps) = (xs(&u, id), undo_steps(&u)); + scrub(&mut u, "Y", 20.0, Modifiers::NONE); + let after = xs(&u, id); + assert!(after[0].1 > before[0].1, "the scrub moved y: {before:?} -> {after:?}"); + assert_eq!(after[0].1, after[1].1, "both points take the scrubbed value"); + assert_eq!((after[0].0, after[1].0), (before[0].0, before[1].0), "x is untouched"); + assert_eq!(undo_steps(&u), steps + 1, "one undo step for the whole scrub"); + // Their x differs (Mixed): there is no value to scrub from, so nothing moves. + assert_eq!(field(&mut u, "X").1, "Mixed"); + let (before, steps) = (xs(&u, id), undo_steps(&u)); + scrub(&mut u, "X", 20.0, Modifiers::NONE); + assert_eq!((xs(&u, id), undo_steps(&u)), (before, steps)); +} From 6fce958ae68a6d04fdf8ab05a3822b97173dbf6f Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 9 Nov 2026 02:53:44 -0500 Subject: [PATCH 08/17] M8: Preferences > Invert zoom direction reverses wheel zoom (#2145) Adds the invert_zoom preference and the pref.invertZoom command, a checkbox in the Preferences window after Scroll wheel zoom. With it on, Cmd/Ctrl+wheel and a plain mouse wheel (with Scroll wheel zoom on) zoom the other way by the same factor; a pinch and panning are unchanged. Documented in docs/ui-parity.md. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 measured and wrote at medium --- crates/engine/src/a11y_keys_tests.rs | 16 ++++++ crates/engine/src/cmd/view.rs | 7 +++ crates/engine/src/prefs.rs | 4 ++ crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/src/canvas/input.rs | 12 ++++- crates/ui-egui/src/canvas/wheel_zoom_tests.rs | 50 ++++++++++++++++++- crates/ui-egui/src/dialogs.rs | 1 + docs/ui-parity.md | 5 +- 8 files changed, 91 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/a11y_keys_tests.rs b/crates/engine/src/a11y_keys_tests.rs index e4892bcae..5dd604670 100644 --- a/crates/engine/src/a11y_keys_tests.rs +++ b/crates/engine/src/a11y_keys_tests.rs @@ -103,3 +103,19 @@ fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_the_systems_s let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap(); assert_eq!((p.ui_scale, p.theme.as_str()), (crate::UI_SCALE_MAX, "dark")); } + +#[test] +fn the_invert_zoom_preference_toggles_and_is_saved() { + let mut s = Session::new(); + assert!(!s.prefs.invert_zoom, "off by default"); + assert_eq!(s.execute("pref.invertZoom", &json!({})).unwrap()["on"], true); + assert!(s.prefs.invert_zoom); + let saved = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(saved["invert_zoom"], true); + let loaded: crate::Prefs = serde_json::from_value(saved).unwrap(); + assert!(loaded.invert_zoom); + assert_eq!(s.execute("pref.invertZoom", &json!({"on": false})).unwrap()["on"], false); + // Preferences saved before the setting existed read it as off. + let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); + assert!(!old.invert_zoom); +} diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 46ca701ff..2f2ec3e1f 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -410,6 +410,13 @@ pub fn specs() -> Vec { false, |s, p| pref(s, p, |x| &mut x.scroll_wheel_zoom) ), + spec!( + "pref.invertZoom", + "Invert zoom direction", + "{on?} reverses wheel zoom: Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom out where they would zoom in (a trackpad pinch and panning are unchanged) → {on}", + false, + |s, p| pref(s, p, |x| &mut x.invert_zoom) + ), spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)), spec!( "pref.uiScale", diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 48d68a901..4225963f5 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -36,6 +36,9 @@ pub struct Prefs { /// A mouse wheel zooms the canvas about the pointer, and Cmd/Ctrl+wheel scrolls it (off: the /// other way round). Trackpads are unchanged. pub scroll_wheel_zoom: bool, + /// Wheel zoom goes the other way: Cmd/Ctrl+wheel (and a plain mouse wheel with Scroll wheel + /// zoom on) zooms out where it would zoom in. A trackpad pinch is unchanged. + pub invert_zoom: bool, /// "system" by default; an explicit "light" or "dark" choice stays saved. pub theme: String, /// The widths of the main window's left and right panels (the interface keeps them within its @@ -136,6 +139,7 @@ impl Default for Prefs { number_keys_opacity: true, show_dimensions: true, scroll_wheel_zoom: false, + invert_zoom: false, theme: "system".into(), panel_widths: (240.0, 240.0), pages_height: None, diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 625ee3bd8..f3ce19403 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -882,6 +882,7 @@ name = "English" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Pasted design layers; the clipboard didn't carry the images of: {layers}" "Remove these results from the file" = "Remove these results from the file" "Scroll wheel zoom" = "Scroll wheel zoom" +"Invert zoom direction" = "Invert zoom direction" "Shared by your team on {server}" = "Shared by your team on {server}" "Slot name" = "Slot name" "Mar" = "Mar" diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 5187fe866..b4cff10aa 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -81,10 +81,18 @@ fn wheel_zoom(app: &mut FigApp, ctx: &egui::Context, zoom: f32, shift: bool) -> if let Some(mouse) = latest { app.canvas.wheel_is_mouse = mouse; } + let invert = app.session.prefs.invert_zoom && !pinch; if !app.session.prefs.scroll_wheel_zoom || !app.canvas.wheel_is_mouse || pinch { - return (wheel, zoom); + return (wheel, if invert { invert_zoom_factor(zoom) } else { zoom }); } - swap_wheel(wheel, zoom, shift, speed) + let (scroll, zoom) = swap_wheel(wheel, zoom, shift, speed); + (scroll, if invert { invert_zoom_factor(zoom) } else { zoom }) +} + +/// The zoom factor for the opposite direction (Preferences ▸ Invert zoom direction): its +/// reciprocal. A factor that is zero, negative or not finite is left as it is. +pub(crate) fn invert_zoom_factor(zoom: f32) -> f32 { + if zoom.is_finite() && zoom > 0.0 { 1.0 / zoom } else { zoom } } /// Whether a scroll event came from a mouse wheel. Only macOS says which device scrolled; elsewhere diff --git a/crates/ui-egui/src/canvas/wheel_zoom_tests.rs b/crates/ui-egui/src/canvas/wheel_zoom_tests.rs index 5b196a4d4..7574e96a4 100644 --- a/crates/ui-egui/src/canvas/wheel_zoom_tests.rs +++ b/crates/ui-egui/src/canvas/wheel_zoom_tests.rs @@ -6,7 +6,7 @@ use egui::{Event, Modifiers, MouseWheelSource, MouseWheelUnit, Pos2, RawInput, R use figcraft_engine::Session; use serde_json::json; -use super::input::{is_mouse_wheel, swap_wheel}; +use super::input::{invert_zoom_factor, is_mouse_wheel, swap_wheel}; use crate::{FigApp, NoServices}; /// The view's zoom, cx and cy. @@ -25,8 +25,13 @@ fn wheel(unit: MouseWheelUnit, dy: f32, modifiers: Modifiers) -> Event { impl Canvas { fn new(scroll_wheel_zoom: bool) -> Self { + Self::with_invert(scroll_wheel_zoom, false) + } + + fn with_invert(scroll_wheel_zoom: bool, invert_zoom: bool) -> Self { let mut c = Self { ctx: egui::Context::default(), app: FigApp::new(Session::new(), Box::new(NoServices)), time: 0.0 }; c.app.run("pref.scrollWheelZoom", json!({"on": scroll_wheel_zoom})); + c.app.run("pref.invertZoom", json!({"on": invert_zoom})); for _ in 0..3 { c.frame(vec![], Modifiers::NONE); } @@ -126,3 +131,46 @@ fn the_swap_keeps_egui_rates() { assert_eq!(swap_wheel(vec2(40.0, 0.0), 1.0, true, speed), (vec2(40.0, 0.0), 1.0)); assert_eq!(swap_wheel(Vec2::ZERO, 1.0, false, speed), (Vec2::ZERO, 1.0)); } + +#[test] +fn invert_zoom_reverses_cmd_or_ctrl_wheel_by_the_same_factor() { + let (mut off, mut on) = (Canvas::with_invert(false, false), Canvas::with_invert(false, true)); + let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::COMMAND), Modifiers::COMMAND); + let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::COMMAND), Modifiers::COMMAND); + assert!(b.0 > a.0 * 1.05, "off zooms in: {a:?} -> {b:?}"); + assert!(d.0 < c.0 / 1.05, "on zooms out: {c:?} -> {d:?}"); + assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "by the same factor"); +} + +#[test] +fn invert_zoom_reverses_a_plain_mouse_wheel_with_scroll_wheel_zoom_on() { + let (mut off, mut on) = (Canvas::with_invert(true, false), Canvas::with_invert(true, true)); + let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::NONE), Modifiers::NONE); + let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::NONE), Modifiers::NONE); + assert!(b.0 > a.0 * 1.05, "off zooms in: {a:?} -> {b:?}"); + assert!(d.0 < c.0 / 1.05, "on zooms out: {c:?} -> {d:?}"); + assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "by the same factor"); + // Without Scroll wheel zoom a plain wheel still scrolls, inverted or not. + let mut plain = Canvas::with_invert(false, true); + let (a, b, _) = plain.gesture(wheel(MouseWheelUnit::Line, -3.0, Modifiers::NONE), Modifiers::NONE); + assert!((b.0 - a.0).abs() < 1e-9 && b.2 > a.2 + 1.0, "scrolls: {a:?} -> {b:?}"); +} + +#[test] +fn a_pinch_zooms_the_same_with_invert_zoom_on_and_off() { + for invert in [false, true] { + let mut c = Canvas::with_invert(false, invert); + let (a, b, _) = c.gesture(Event::Zoom(1.5), Modifiers::NONE); + assert!((b.0 / a.0 - 1.5).abs() < 1e-3, "invert {invert}: {a:?} -> {b:?}"); + } +} + +#[test] +fn the_inverted_factor_is_the_reciprocal_and_guards_bad_values() { + assert!((invert_zoom_factor(2.0) - 0.5).abs() < 1e-6); + assert_eq!(invert_zoom_factor(1.0), 1.0); + for bad in [0.0, -1.0, f32::NAN, f32::INFINITY] { + let r = invert_zoom_factor(bad); + assert!(r == bad || (r.is_nan() && bad.is_nan()), "{bad} left alone"); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 52170af44..e60928fd1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -246,6 +246,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { (tr("Show dimensions on objects"), prefs.show_dimensions, "pref.showDimensions"), (tr("Use number keys for opacity"), prefs.number_keys_opacity, "pref.numberKeysOpacity"), (tr("Scroll wheel zoom"), prefs.scroll_wheel_zoom, "pref.scrollWheelZoom"), + (tr("Invert zoom direction"), prefs.invert_zoom, "pref.invertZoom"), (tr("Reduce motion"), prefs.reduce_motion, "pref.reduceMotion"), ] { if crate::widgets::checkbox(ui, val, label) { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 463be3b47..6984cf8b0 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-04 · **Change:** minor (the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); 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-09 · **Change:** patch (Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); 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: @@ -109,7 +109,7 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| -| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) | +| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); Preferences ▸ Invert zoom direction (Figma's Preferences menu has it, checked 2026-11-09; #2145) reverses wheel zoom: Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom out where they would zoom in, by the same factor, while a trackpad pinch and panning are unchanged (only the setting's existence was measured, so the pinch is left alone); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) | | Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | guides belong to the page or a top-level frame (a frame moved into another keeps its guides but they stop applying); a click selects one for Delete, the arrows or the context menu's Remove guide; Alt-drag drops a copy; Alt while dragging measures to the layers in the frame it crosses; ruler numbering checked by eye only | | Frame labels (rotated, right-to-left), size badge | done | | | Outline mode | done | Outlines, outlines for the selection, mask outlines, frame outlines; shapes are clicked on their outlines, reaching layers behind; View ▸ Outlines ▸ Include hidden layers (`view.outlinesHiddenLayers`) outlines hidden layers too and Include object bounds (`view.outlinesObjectBounds`) draws the box of every layer whose outline isn't its box (groups, text, ellipses, vectors); what either draws can be clicked there (a hidden layer on its outline, a group on its box) and nothing else can; both are view settings like the other outline flags (per file, off in a new one, checked in the menu); the lines are light on a dark page (`outline_options_tests`). The main menu now shows a check mark beside every View toggle that is on | @@ -187,6 +187,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-09 | patch | Preferences ▸ Invert zoom direction (#2145): Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom the other way; pinch and panning unchanged. Numbers unchanged. | | 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` | | 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged | | 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. | From 1ef0c16fdf81b1488d458fc4dc158c20d65ce6d8 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 9 Nov 2026 15:11:14 -0500 Subject: [PATCH 09/17] M8: invert zoom direction tests: Ctrl wheel, panning unchanged, pinch both ways, off persists (#2147 review) Tests only. Ctrl+wheel joins Cmd+wheel; panning (plain wheel, Cmd/Ctrl+wheel and Shift+wheel with Scroll wheel zoom on) is compared between invert on and off; the pinch runs with Scroll wheel zoom on and off and with factors above and below 1; the pref's off value is saved and reloaded. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 triaged and briefed; Sonnet 5.5 verified and wrote at medium --- crates/engine/src/a11y_keys_tests.rs | 5 +++ crates/ui-egui/src/canvas/wheel_zoom_tests.rs | 45 +++++++++++++------ 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/a11y_keys_tests.rs b/crates/engine/src/a11y_keys_tests.rs index 5dd604670..6fc5f2396 100644 --- a/crates/engine/src/a11y_keys_tests.rs +++ b/crates/engine/src/a11y_keys_tests.rs @@ -115,6 +115,11 @@ fn the_invert_zoom_preference_toggles_and_is_saved() { let loaded: crate::Prefs = serde_json::from_value(saved).unwrap(); assert!(loaded.invert_zoom); assert_eq!(s.execute("pref.invertZoom", &json!({"on": false})).unwrap()["on"], false); + assert!(!s.prefs.invert_zoom); + let saved = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(saved["invert_zoom"], false); + let loaded: crate::Prefs = serde_json::from_value(saved).unwrap(); + assert!(!loaded.invert_zoom, "off persists after being on"); // Preferences saved before the setting existed read it as off. let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); assert!(!old.invert_zoom); diff --git a/crates/ui-egui/src/canvas/wheel_zoom_tests.rs b/crates/ui-egui/src/canvas/wheel_zoom_tests.rs index 7574e96a4..b5a36a7de 100644 --- a/crates/ui-egui/src/canvas/wheel_zoom_tests.rs +++ b/crates/ui-egui/src/canvas/wheel_zoom_tests.rs @@ -134,12 +134,14 @@ fn the_swap_keeps_egui_rates() { #[test] fn invert_zoom_reverses_cmd_or_ctrl_wheel_by_the_same_factor() { - let (mut off, mut on) = (Canvas::with_invert(false, false), Canvas::with_invert(false, true)); - let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::COMMAND), Modifiers::COMMAND); - let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::COMMAND), Modifiers::COMMAND); - assert!(b.0 > a.0 * 1.05, "off zooms in: {a:?} -> {b:?}"); - assert!(d.0 < c.0 / 1.05, "on zooms out: {c:?} -> {d:?}"); - assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "by the same factor"); + for mods in [Modifiers::COMMAND, Modifiers::CTRL] { + let (mut off, mut on) = (Canvas::with_invert(false, false), Canvas::with_invert(false, true)); + let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, 3.0, mods), mods); + let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, 3.0, mods), mods); + assert!(b.0 > a.0 * 1.05, "{mods:?} off zooms in: {a:?} -> {b:?}"); + assert!(d.0 < c.0 / 1.05, "{mods:?} on zooms out: {c:?} -> {d:?}"); + assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "{mods:?} by the same factor"); + } } #[test] @@ -150,18 +152,33 @@ fn invert_zoom_reverses_a_plain_mouse_wheel_with_scroll_wheel_zoom_on() { assert!(b.0 > a.0 * 1.05, "off zooms in: {a:?} -> {b:?}"); assert!(d.0 < c.0 / 1.05, "on zooms out: {c:?} -> {d:?}"); assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "by the same factor"); - // Without Scroll wheel zoom a plain wheel still scrolls, inverted or not. - let mut plain = Canvas::with_invert(false, true); - let (a, b, _) = plain.gesture(wheel(MouseWheelUnit::Line, -3.0, Modifiers::NONE), Modifiers::NONE); - assert!((b.0 - a.0).abs() < 1e-9 && b.2 > a.2 + 1.0, "scrolls: {a:?} -> {b:?}"); +} + +/// Panning is never inverted: the scroll vector is the same with the preference on and off. +#[test] +fn invert_zoom_leaves_panning_unchanged() { + // (Scroll wheel zoom, modifiers): a plain wheel without it, Cmd/Ctrl+wheel with it, Shift+wheel. + for (swz, mods) in [(false, Modifiers::NONE), (true, Modifiers::COMMAND), (true, Modifiers::CTRL), (true, Modifiers::SHIFT)] { + let (mut off, mut on) = (Canvas::with_invert(swz, false), Canvas::with_invert(swz, true)); + let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, -3.0, mods), mods); + let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, -3.0, mods), mods); + assert!((b.0 - a.0).abs() < 1e-9 && (d.0 - c.0).abs() < 1e-9, "{mods:?} no zoom"); + assert!(b.1 != a.1 || b.2 != a.2, "{mods:?} off pans: {a:?} -> {b:?}"); + assert!( + ((b.1 - a.1) - (d.1 - c.1)).abs() < 1e-9 && ((b.2 - a.2) - (d.2 - c.2)).abs() < 1e-9, + "{mods:?} same pan: {a:?} -> {b:?} vs {c:?} -> {d:?}" + ); + } } #[test] fn a_pinch_zooms_the_same_with_invert_zoom_on_and_off() { - for invert in [false, true] { - let mut c = Canvas::with_invert(false, invert); - let (a, b, _) = c.gesture(Event::Zoom(1.5), Modifiers::NONE); - assert!((b.0 / a.0 - 1.5).abs() < 1e-3, "invert {invert}: {a:?} -> {b:?}"); + for (swz, invert, factor) in + [(false, false, 1.5), (false, true, 1.5), (true, false, 1.5), (true, true, 1.5), (true, true, 0.5), (false, true, 0.5)] + { + let mut c = Canvas::with_invert(swz, invert); + let (a, b, _) = c.gesture(Event::Zoom(factor), Modifiers::NONE); + assert!((b.0 / a.0 - f64::from(factor)).abs() < 1e-3, "scroll wheel zoom {swz}, invert {invert}, x{factor}: {a:?} -> {b:?}"); } } From da81d55a64460d9d83f2c9052badcf60e47bc6de Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 10 Nov 2026 02:14:17 -0500 Subject: [PATCH 10/17] Transform: Shift-rotating inside a rotated frame lands the page rotation on multiples of 15 Measured in Figma: a layer's rotation field shows its rotation on the page, also inside a rotated frame or group, and Shift snaps that value. A test pins it; the doc comment on shared_rotation_degrees says so. Review item on #2096. Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and wrote; Sonnet 5.5 measured in Figma --- crates/engine/src/canvas/tools/handles.rs | 7 ++-- .../engine/src/transform_behaviour_tests.rs | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/canvas/tools/handles.rs b/crates/engine/src/canvas/tools/handles.rs index 75410ec75..ecd29e67a 100644 --- a/crates/engine/src/canvas/tools/handles.rs +++ b/crates/engine/src/canvas/tools/handles.rs @@ -132,9 +132,10 @@ pub fn selection_frame(doc: &Document, sel: &[NodeId]) -> Option<(Affine, f64, f Some((rot * Affine::translate((b.x0, b.y0)), b.width(), b.height())) } -/// The rotation the Design panel shows for `ids` (degrees, counter-clockwise positive) when they -/// all share one, else 0 (the handle box is page-aligned then). Axis-aligned layers give 0, which -/// is the same as 90, 180 and 270 for stepping in 15 degrees. +/// The rotation `ids` share on the page (degrees, counter-clockwise positive), else 0 (the handle +/// box is page-aligned then). Figma's rotation field shows this page rotation, also for a layer +/// inside a rotated frame or group, and Shift lands it on multiples of 15 degrees. Axis-aligned +/// layers give 0, which is the same as 90, 180 and 270 for stepping in 15 degrees. pub fn shared_rotation_degrees(doc: &Document, ids: &[NodeId]) -> f64 { shared_rotation(doc, ids).map_or(0.0, |a| -a.to_degrees()) } diff --git a/crates/engine/src/transform_behaviour_tests.rs b/crates/engine/src/transform_behaviour_tests.rs index 1ad84fff0..fe6709ee4 100644 --- a/crates/engine/src/transform_behaviour_tests.rs +++ b/crates/engine/src/transform_behaviour_tests.rs @@ -403,6 +403,39 @@ fn shift_rotating_lands_on_multiples_of_15_not_steps_from_the_start() { assert!(near(got, 0.0), "-2.27° rounds to 0°: {got}"); } +/// Rotates a 120 × 80 layer inside a frame turned `frame_rot`, the layer at `start` on the page, +/// by `turn` from its corner, and returns its rotation on the page. +fn rotate_drag_in_frame(frame_rot: f64, start: f64, turn: f64, m: Mods) -> f64 { + let mut s = session(); + let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 100, "width": 400, "height": 400}))); + set(&mut s, &[f], json!({"rotation": frame_rot})); + let r = rect(&mut s, 100.0, 100.0, 120.0, 80.0, Some(f)); + set(&mut s, &[r], json!({"rotation": start - frame_rot})); + let page = |s: &Session| rotation_degrees(s.doc().unwrap().world_transform(r)); + assert!(near(page(&s), start), "the layer starts at {start}° on the page: {}", page(&s)); + ex(&mut s, "select.set", json!({"ids": [r]})); + let world = s.doc().unwrap().world_transform(r); + let (c, corner) = (world * Point::new(60.0, 40.0), world * Point::ZERO); + let from = corner + (corner - c) * 0.15; + let a = (from - c).atan2() - turn.to_radians(); + let to = c + figcraft_geom::Vec2::from_angle(a) * 80.0; + drag(&mut s, from, to, m); + page(&s) +} + +#[test] +fn shift_rotating_inside_a_rotated_frame_lands_the_page_rotation_on_multiples_of_15() { + // Measured in Figma (2026-11-10): the rotation field of a layer inside a frame turned 10° shows + // its rotation on the page, and Shift lands that on multiples of 15°: from 7°, turns of 40°, + // 6° and -10° end at 45°, 15° and 0°; without Shift 6° ends at 13°. Groups behave the same. + for (turn, want) in [(40.0, 45.0), (6.0, 15.0), (-10.0, 0.0)] { + let got = rotate_drag_in_frame(10.0, 7.0, turn, SHIFT); + assert!(near(got, want), "7° on the page turned by {turn}° with Shift: {got}, want {want}"); + } + let got = rotate_drag_in_frame(10.0, 7.0, 6.0, NONE); + assert!(near(got, 13.0), "a free turn: {got}"); +} + #[test] fn groups_scale_children_keeping_their_rotation_and_flip() { let mut s = session(); From 6d128d3377d38022c6afabd0ff35d656d9741158 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 10 Nov 2026 02:48:54 -0500 Subject: [PATCH 11/17] Cursors: the crosshair is drawn dark with a white edge, so it shows on a white frame (#2198) Cursor::Crosshair was the system's crosshair, whose colour follows the pointer style and was white on Windows, lost on the default white frame. It is now Glyph::Crosshair, painted from lines like the pen: a plus with 2 px open at the centre, a 3 px white edge under 1 px dark lines. Prototype connections still set the system crosshair directly. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/ui-egui/src/canvas/cursors.rs | 28 +++++++++++++--- crates/ui-egui/src/cursors_tests.rs | 48 +++++++++++++++++++++++++--- docs/ui-parity.md | 5 +-- 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/canvas/cursors.rs b/crates/ui-egui/src/canvas/cursors.rs index dec2a1b31..d518dde37 100644 --- a/crates/ui-egui/src/canvas/cursors.rs +++ b/crates/ui-egui/src/canvas/cursors.rs @@ -1,7 +1,7 @@ //! The pointer on the canvas: each engine cursor ([`figcraft_engine::tools::Cursor`]) as one of //! the system's cursors, or as a small glyph FigCraft draws at the pointer with the system's //! hidden (a rotate cursor turned to its corner, the pen's states, the eyedropper, the comment -//! pin). The glyphs are original, drawn from lines and the code-drawn icons: no cursor images. +//! pin, the crosshair of the shape and Text tools). The glyphs are original, drawn from lines and the code-drawn icons: no cursor images. use egui::{Color32, CursorIcon, Painter, Pos2, Shape, Stroke, pos2, vec2}; use figcraft_engine::tools::{Cursor, PenCursor}; @@ -17,6 +17,9 @@ pub enum Glyph { Pen(PenCursor), Eyedropper, Comment, + /// A plus open at the centre, for the shape and Text tools (the system's follows the + /// system's pointer colour, and a white one is lost on a white frame). + Crosshair, } /// What the pointer shows. @@ -46,7 +49,7 @@ pub fn of(cursor: Cursor) -> Shown { use Shown::{Drawn, System}; match cursor { Cursor::Default => System(CursorIcon::Default), - Cursor::Crosshair => System(CursorIcon::Crosshair), + Cursor::Crosshair => Drawn(Glyph::Crosshair), Cursor::Move => System(CursorIcon::Move), Cursor::Resize(a) if a.is_finite() => System(resize_icon(a)), Cursor::Resize(_) => System(CursorIcon::Default), @@ -85,7 +88,7 @@ impl Shown { /// A name for it (`ui.inspect`): the CSS cursor names where there is one ("default", /// "nwse-resize", "copy"), and "rotate" (with its angle), "pen", "pen-close", "pen-add", - /// "pen-bend", "eyedropper" and "comment" for the drawn ones. + /// "pen-bend", "eyedropper", "comment" and "crosshair" for the drawn ones. pub fn name(self) -> String { match self { Shown::System(i) => match i { @@ -114,6 +117,7 @@ impl Shown { Shown::Drawn(Glyph::Pen(PenCursor::Bend)) => "pen-bend".into(), Shown::Drawn(Glyph::Eyedropper) => "eyedropper".into(), Shown::Drawn(Glyph::Comment) => "comment".into(), + Shown::Drawn(Glyph::Crosshair) => "crosshair".into(), } } @@ -127,7 +131,7 @@ impl Shown { } /// Dark lines over a light edge: readable on any page and artwork. -const INK: Color32 = Color32::from_rgb(0x1E, 0x1E, 0x1E); +pub(crate) const INK: Color32 = Color32::from_rgb(0x1E, 0x1E, 0x1E); const EDGE: Color32 = Color32::WHITE; /// A line through `pts` with the light edge under it. @@ -167,6 +171,22 @@ pub fn paint(painter: &Painter, at: Pos2, glyph: Glyph) { line(painter, &[tip - t * 4.5 + n * 3.5, tip + t * 1.0, tip - t * 4.5 - n * 3.5]); } } + Glyph::Crosshair => { + // A plus centred on the pointer, open at the centre so its pixel stays visible; the + // light edge goes under both arms of each line first, then the dark line on top. + let arms = [ + (vec2(-8.0, 0.0), vec2(-2.0, 0.0)), + (vec2(2.0, 0.0), vec2(8.0, 0.0)), + (vec2(0.0, -8.0), vec2(0.0, -2.0)), + (vec2(0.0, 2.0), vec2(0.0, 8.0)), + ]; + for (a, b) in arms { + painter.add(Shape::line_segment([at + a, at + b], Stroke::new(3.0, EDGE))); + } + for (a, b) in arms { + painter.add(Shape::line_segment([at + a, at + b], Stroke::new(1.0, INK))); + } + } Glyph::Pen(state) => { // A cross open at the centre (the point it lands on stays visible), and what a click // does at its lower right. diff --git a/crates/ui-egui/src/cursors_tests.rs b/crates/ui-egui/src/cursors_tests.rs index 94109dc22..bb24a2469 100644 --- a/crates/ui-egui/src/cursors_tests.rs +++ b/crates/ui-egui/src/cursors_tests.rs @@ -22,7 +22,7 @@ fn every_engine_cursor_has_a_system_cursor_or_a_glyph() { // (engine cursor, what shows, its name in `ui.inspect`) let rows: Vec<(Cursor, Shown, &str)> = vec![ (Cursor::Default, System(CursorIcon::Default), "default"), - (Cursor::Crosshair, System(CursorIcon::Crosshair), "crosshair"), + (Cursor::Crosshair, Drawn(Glyph::Crosshair), "crosshair"), (Cursor::Move, System(CursorIcon::Move), "move"), (Cursor::Resize(0.0), System(CursorIcon::ResizeHorizontal), "ew-resize"), (Cursor::Resize(90.0), System(CursorIcon::ResizeVertical), "ns-resize"), @@ -59,6 +59,44 @@ fn every_engine_cursor_has_a_system_cursor_or_a_glyph() { assert_eq!(of(Cursor::Rotate(f64::INFINITY)), Drawn(Glyph::Rotate(0.0))); } +#[test] +fn the_crosshair_is_drawn_dark_with_a_white_edge_so_it_shows_on_white() { + use egui::epaint::Shape; + let shown = of(Cursor::Crosshair); + assert_eq!(shown, Shown::Drawn(Glyph::Crosshair)); + // The system's pointer is hidden under it, whatever its colour. + assert_eq!(shown.icon(), CursorIcon::None); + let at = pos2(50.0, 40.0); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let ctx = ui.ctx().clone(); + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("crosshair_test")); + crate::cursors::paint(&ctx.layer_painter(layer), at, Glyph::Crosshair); + }); + out.textures_delta.clear(); + let mut strokes = Vec::new(); + for clipped in &out.shapes { + if let Shape::LineSegment { points, stroke } = &clipped.shape { + strokes.push((points.to_vec(), stroke.color, stroke.width)); + } + } + let dark: Vec<_> = strokes.iter().filter(|s| s.1 == crate::cursors::INK).collect(); + let white: Vec<_> = strokes.iter().filter(|s| s.1 == egui::Color32::WHITE).collect(); + assert!(!dark.is_empty() && !white.is_empty(), "{strokes:?}"); + // The white edge is wider than the dark line and is painted first. + assert!(white.iter().all(|w| w.2 > dark[0].2), "{strokes:?}"); + assert_eq!(strokes[0].1, egui::Color32::WHITE); + // The arms are centred on the pointer and leave its pixel clear. + for (pts, ..) in &dark { + for p in pts { + let d = (p.x - at.x).abs().max((p.y - at.y).abs()); + assert!((2.0..=8.0).contains(&d), "{p:?}"); + assert!((p.x - at.x).abs() < 0.01 || (p.y - at.y).abs() < 0.01, "{p:?}"); + } + } + assert_eq!(dark.len(), 4); +} + #[test] fn resize_arrows_follow_the_angle_in_eighths() { // (degrees, the two-headed arrow): each covers 45° about its own direction, any turn. @@ -134,10 +172,10 @@ fn the_window_asks_for_the_cursor_of_the_tool_under_the_pointer() { ("move", edge, &[], no, CursorIcon::ResizeHorizontal, "ew-resize"), ("move", on, &[], alt, CursorIcon::Copy, "copy"), ("hand", empty, &[], no, CursorIcon::Grab, "grab"), - ("rectangle", empty, &[], no, CursorIcon::Crosshair, "crosshair"), - ("frame", on, &[], no, CursorIcon::Crosshair, "crosshair"), - ("slice", empty, &[], no, CursorIcon::Crosshair, "crosshair"), - ("text", empty, &[], no, CursorIcon::Crosshair, "crosshair"), + ("rectangle", empty, &[], no, CursorIcon::None, "crosshair"), + ("frame", on, &[], no, CursorIcon::None, "crosshair"), + ("slice", empty, &[], no, CursorIcon::None, "crosshair"), + ("text", empty, &[], no, CursorIcon::None, "crosshair"), ("pen", empty, &[], no, CursorIcon::None, "pen"), ("comment", empty, &[], no, CursorIcon::None, "comment"), ("move", empty, &[Key::Space], no, CursorIcon::Grab, "grab"), diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 463be3b47..b7ef27d8e 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-04 · **Change:** minor (the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); 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-10 · **Change:** minor (the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); 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: @@ -122,7 +122,7 @@ interaction feel not yet compared. | Gamut warnings for Display P3 files | done | colour picker note; View ▸ Soft-proof sRGB stripes what sRGB can't show (the canvas itself is sRGB) | | Gradient and padding/gap handles on the canvas | done | the gradient's handles and stops show while its picker is open, and dragging one keeps the picker open (#1852) | | Hover outline, selection colours, handle sizes | done | measured from UI3 screenshots; the outline follows the layer's geometry, rounded corners (each radius and the smoothing) included, whatever the stroke alignment (#1506) | -| Cursors per tool and modifier | done | the engine names a cursor for every tool, modifier and hover target (`tool.overlay.cursor`, tabled in `cursor_tests`) and the UI shows the system's or draws its own at the pointer (`cursors.rs`, `cursors_tests`; original glyphs from lines and the code-drawn icons). Arrow for Move and Scale; two-headed arrows on the handles that follow the handle and the box's rotation; outside a corner a curved rotate arrow turned to that corner, turning with the pointer while rotating; crosshair for the frame, section, slice, shape, pencil and Text tools; the I-beam over a text with the Text tool and over the text being edited; the pen as a cross with what a click does beside it (nothing: a new point; a ring: closes, connects to or goes on from a point; a plus: adds a point on a segment, also Alt in vector edit mode; a curve: bends, the Bend tool or Cmd/Ctrl); open and closed hand for the Hand tool, Space and the middle button; zoom in and out (Alt) while Z is held; eyedropper; comment pin; the copy cursor with Alt over a selected layer and while Alt-dragging; not-allowed while a drag resizes, rotates or moves a layer inside an instance (it is undone on release) and over a layers row that takes no drop. Ruler guides and gradient, crop and padding handles keep their own cursors (the tool's used to replace them). `ui.inspect` reports `cursor`. Left: no cursor for removing a point with the pen (the pen doesn't remove points); the drawn cursors follow the pointer a frame behind the system's; not compared with Figma's side by side | +| Cursors per tool and modifier | done | the engine names a cursor for every tool, modifier and hover target (`tool.overlay.cursor`, tabled in `cursor_tests`) and the UI shows the system's or draws its own at the pointer (`cursors.rs`, `cursors_tests`; original glyphs from lines and the code-drawn icons). Arrow for Move and Scale; two-headed arrows on the handles that follow the handle and the box's rotation; outside a corner a curved rotate arrow turned to that corner, turning with the pointer while rotating; crosshair for the frame, section, slice, shape, pencil and Text tools, drawn dark with a white edge so it shows on any canvas colour whatever the system's pointer style (#2198); the I-beam over a text with the Text tool and over the text being edited; the pen as a cross with what a click does beside it (nothing: a new point; a ring: closes, connects to or goes on from a point; a plus: adds a point on a segment, also Alt in vector edit mode; a curve: bends, the Bend tool or Cmd/Ctrl); open and closed hand for the Hand tool, Space and the middle button; zoom in and out (Alt) while Z is held; eyedropper; comment pin; the copy cursor with Alt over a selected layer and while Alt-dragging; not-allowed while a drag resizes, rotates or moves a layer inside an instance (it is undone on release) and over a layers row that takes no drop. Ruler guides and gradient, crop and padding handles keep their own cursors (the tool's used to replace them). `ui.inspect` reports `cursor`. Left: no cursor for removing a point with the pen (the pen doesn't remove points); the drawn cursors follow the pointer a frame behind the system's; not compared with Figma's side by side | | Multiplayer cursors, cursor chat, spotlight | partial | in live sessions: named cursors, chat bubbles that fade, `/` to chat, spotlight from your avatar's menu with a banner; no audio or reactions | ## Keyboard shortcuts @@ -187,6 +187,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-10 | patch | M1, #2198: with a shape, frame, slice or Text tool the pointer was the system's crosshair, white under some pointer styles on Windows and lost on the default white frame; it is now drawn at the pointer in dark lines with a white edge, open at the centre (`Glyph::Crosshair`), so it shows on any canvas colour. | | 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` | | 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged | | 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. | From c48d844d2258613a55bd6fe9ef1c8254c1fcfade Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 10 Nov 2026 13:11:56 -0500 Subject: [PATCH 12/17] M4: round the sharp corners of a closed path (pathops, #2222) Add figcraft_pathops::round_corners(path, radius, smoothing): every vertex of a closed subpath that is still sharp rounds with a circular arc, convex and concave alike. The sweep's noise is cleaned first (short pieces welded, straight runs merged), smooth joins are not corners, and a corner shrinks to what the edges beside it can hold (half an edge when both ends round, the whole piece beside a smooth join). Corners between straight edges use smoothed_corner; corners beside a curve cut both sides and join them with one cubic. A subpath with nothing to round comes back unchanged. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Fable 5.1 designed; Sonnet 5.5 wrote at medium --- crates/pathops/src/lib.rs | 6 + crates/pathops/src/round.rs | 312 ++++++++++++++++++++++++++++++ crates/pathops/src/round_tests.rs | 238 +++++++++++++++++++++++ 3 files changed, 556 insertions(+) create mode 100644 crates/pathops/src/round.rs create mode 100644 crates/pathops/src/round_tests.rs diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index c24a22248..86f8764c4 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -4,6 +4,8 @@ //! sweep-line topology from `linesweeper`); results use the even-odd rule. //! - [`stroke_outline`]: a stroke as a fill, with Figma's Inside / Center / Outside alignment, //! dashes, caps, joins, arrowheads, variable width profiles ([`width`]) and brushes ([`brush`]). +//! - [`round_corners`]: a corner radius (and smoothing) on every corner still sharp in a closed path, +//! as a boolean group draws its result. //! //! Every sweep is capped ([`MAX_SEGMENTS`], [`MAX_CROSSINGS`], and [`MAX_FOLD_SEGMENTS`] over a //! fold): past the caps an operation is refused with [`PathOpsError::TooComplex`]. @@ -24,6 +26,9 @@ mod dot_dash_tests; #[cfg(test)] mod ops_tests; mod points; +mod round; +#[cfg(test)] +mod round_tests; #[cfg(test)] mod rounded_side_strokes_tests; #[cfg(test)] @@ -36,6 +41,7 @@ use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, Vect use figcraft_geom::kurbo::{self, BezPath, Cap, Join, PathEl, PathSeg, Point, Shape, Vec2}; pub use budget::{Complexity, FoldBudget, MAX_CROSSINGS, MAX_FOLD_SEGMENTS, MAX_SEGMENTS, complexity}; +pub use round::round_corners; #[derive(Debug, PartialEq)] pub enum PathOpsError { diff --git a/crates/pathops/src/round.rs b/crates/pathops/src/round.rs new file mode 100644 index 000000000..40ab03759 --- /dev/null +++ b/crates/pathops/src/round.rs @@ -0,0 +1,312 @@ +//! A corner radius on a closed path: every vertex that is still sharp rounds with a circular arc, +//! convex and concave alike, the way a boolean group draws its result. +//! +//! What a sweep hands back is noisy: straight edges split at collinear points, pieces a hair long, +//! one real corner cut into two half turns. So each subpath is cleaned first ([`clean`]), then each +//! vertex is judged by the turn it makes, and a corner that does not fit shrinks to what the edges +//! beside it can hold ([`Ctx::corner`]). + +use figcraft_geom::kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv, PathEl, PathSeg, Point, Shape, Vec2}; +use std::f64::consts::PI; + +/// The smallest turn (radians) that counts as a corner. The sweep moves end points by up to its +/// own 1e-6 accuracy, which can tilt a short piece by more than 1e-6 rad. +const SHARP_TURN: f64 = 1e-3; +/// Accuracy of arc length measurements. +const ACC: f64 = 1e-6; + +#[derive(Clone, Copy)] +enum Seg { + Line(Point, Point), + Cubic(CubicBez), +} + +impl Seg { + fn start(&self) -> Point { + match self { + Self::Line(a, _) => *a, + Self::Cubic(c) => c.p0, + } + } + + fn end(&self) -> Point { + match self { + Self::Line(_, b) => *b, + Self::Cubic(c) => c.p3, + } + } + + fn set_start(&mut self, p: Point) { + match self { + Self::Line(a, _) => *a = p, + Self::Cubic(c) => c.p0 = p, + } + } + + fn is_line(&self) -> bool { + matches!(self, Self::Line(..)) + } + + /// As a cubic (a line gets its handles at the thirds). + fn cubic(&self) -> CubicBez { + match self { + Self::Line(a, b) => CubicBez::new(*a, a.lerp(*b, 1.0 / 3.0), a.lerp(*b, 2.0 / 3.0), *b), + Self::Cubic(c) => *c, + } + } + + fn len(&self) -> f64 { + match self { + Self::Line(a, b) => (*b - *a).hypot(), + Self::Cubic(c) => c.arclen(ACC), + } + } + + /// The length of the control polygon: cheap, and zero only for a point. + fn span(&self) -> f64 { + let c = self.cubic(); + (c.p1 - c.p0).hypot() + (c.p2 - c.p1).hypot() + (c.p3 - c.p2).hypot() + } + + /// The cubic leaving the start of the segment, or (`from_start` false) leaving its end. + fn outward(&self, from_start: bool) -> CubicBez { + let c = self.cubic(); + if from_start { c } else { CubicBez::new(c.p3, c.p2, c.p1, c.p0) } + } +} + +/// Unit direction in which `c` leaves its start (skipping coincident handles). +fn start_dir(c: &CubicBez) -> Option { + [c.p1, c.p2, c.p3].into_iter().map(|p| p - c.p0).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot()) +} + +/// The point `t` along `c` from its start, the unit direction back toward the start there, and the +/// parameter reached. +fn trim(c: &CubicBez, t: f64, line: bool, len: f64) -> Option<(Point, Vec2, f64)> { + if line { + if !len.is_finite() || len <= 0.0 { + return None; + } + let u = (c.p3 - c.p0) / len; + return Some((c.p0 + u * t, -u, t / len)); + } + let k = c.inv_arclen(t, ACC).clamp(0.0, 1.0); + let d = c.deriv().eval(k).to_vec2(); + let back = if d.hypot() > 1e-9 { -d / d.hypot() } else { -start_dir(c)? }; + Some((c.eval(k), back, k)) +} + +/// One rounded corner: the curves from the cut on the incoming segment to the cut on the outgoing +/// one, and where those cuts sit on their segments (each segment's own parameter). +struct Corner { + curves: Vec, + t_in: f64, + t_out: f64, +} + +/// `path` with every corner still sharp rounded by `radius` (and corner `smoothing`, 0..=1, between +/// straight edges). A corner shrinks to what its edges can hold: two corners wanting more than +/// half an edge share it half and half, a corner whose far end is smooth may use all of it. A +/// closed subpath with no sharp corner, an open one, and a path with a bad radius or coordinates +/// come back as they are. +pub fn round_corners(path: &BezPath, radius: f64, smoothing: f64) -> BezPath { + if !(radius.is_finite() && radius > 0.0) || path.elements().is_empty() || !crate::finite(path) { + return path.clone(); + } + let smoothing = if smoothing.is_finite() { smoothing.clamp(0.0, 1.0) } else { 0.0 }; + let bb = path.bounding_box(); + let weld = (bb.width().max(bb.height()) * 1e-6).max(1e-9); + let mut out = BezPath::new(); + for sub in figcraft_geom::split_subpaths(path) { + let closed = matches!(sub.elements().last(), Some(PathEl::ClosePath)); + match closed.then(|| round_subpath(&sub, radius, smoothing, weld)).flatten() { + Some(p) => out.extend(p.elements().iter().copied()), + None => out.extend(sub.elements().iter().copied()), + } + } + out +} + +/// The segments of a closed subpath without the sweep's noise: pieces shorter than `weld` dropped, +/// each segment starting where the one before ends, and straight runs merged into one line. +fn clean(sub: &BezPath, weld: f64) -> Vec { + let mut segs: Vec = sub + .segments() + .map(|s| match s { + PathSeg::Line(l) => Seg::Line(l.p0, l.p1), + other => Seg::Cubic(other.to_cubic()), + }) + .filter(|s| s.span() >= weld) + .collect(); + let n = segs.len(); + for i in 0..n { + let prev_end = segs.get((i + n - 1) % n).map(Seg::end); + if let (Some(p), Some(s)) = (prev_end, segs.get_mut(i)) { + s.set_start(p); + } + } + merge_runs(segs, weld) +} + +/// Two lines A-B and B-C become A-C when B lies within `weld` of that line and between A and C +/// (a distance test, so a long edge with a real slight kink is not straightened). Cyclic. +fn merge_runs(segs: Vec, weld: f64) -> Vec { + let mergeable = |a: &Seg, b: &Seg| match (a, b) { + (Seg::Line(p, q), Seg::Line(_, r)) => { + let (d, e) = (*r - *p, *q - *p); + let l = d.hypot(); + l > 1e-12 && (d.cross(e) / l).abs() <= weld && e.dot(*r - *q) > 0.0 + } + _ => false, + }; + let mut merged: Vec = Vec::with_capacity(segs.len()); + for s in segs { + match merged.last_mut() { + Some(last) if mergeable(last, &s) => *last = Seg::Line(last.start(), s.end()), + _ => merged.push(s), + } + } + while merged.len() > 2 { + let (Some(first), Some(last)) = (merged.first().copied(), merged.last().copied()) else { break }; + if !mergeable(&last, &first) { + break; + } + if let Some(f) = merged.first_mut() { + *f = Seg::Line(last.start(), first.end()); + } + merged.pop(); + } + merged +} + +/// Everything the corner of one vertex needs. Vertex `i` is where segment `i - 1` ends and +/// segment `i` starts. +struct Ctx<'a> { + segs: &'a [Seg], + /// Segment lengths, measured only for segments with a corner at one end. + lens: &'a [Option], + /// Per vertex: the turn between its segments and the length it wants along each of them. + wants: &'a [Option<(f64, f64)>], + radius: f64, + smoothing: f64, + weld: f64, +} + +/// The angle between the segments at each vertex (pi is straight on, 0 folded back) and the +/// length a corner there wants along each; `None` where the vertex is not a corner. +fn wants(segs: &[Seg], radius: f64, smoothing: f64) -> Vec> { + let n = segs.len(); + (0..n) + .map(|i| { + let (a, b) = (segs.get((i + n - 1) % n)?, segs.get(i)?); + let (u1, u2) = (start_dir(&a.outward(false))?, start_dir(&b.outward(true))?); + let angle = u1.dot(u2).clamp(-1.0, 1.0).acos(); + if angle < 1e-6 || PI - angle < SHARP_TURN { + return None; + } + let t = radius / (angle / 2.0).tan(); + let want = if a.is_line() && b.is_line() { (1.0 + smoothing) * t } else { t }; + want.is_finite().then_some((angle, want)) + }) + .collect() +} + +impl Ctx<'_> { + fn want(&self, i: usize) -> f64 { + self.wants.get(i % self.segs.len().max(1)).copied().flatten().map_or(0.0, |(_, w)| w) + } + + /// What a corner may use of a segment of `len`: half of it, or what the corner at its other + /// end leaves, whichever is more. + fn room(len: f64, other: f64) -> f64 { + (len / 2.0).max(len - other) + } + + fn corner(&self, i: usize) -> Option { + let n = self.segs.len(); + let (angle, _) = self.wants.get(i).copied().flatten()?; + let prev = (i + n - 1) % n; + let (a, b) = (self.segs.get(prev)?, self.segs.get(i)?); + let (la, lb) = (self.lens.get(prev).copied().flatten()?, self.lens.get(i).copied().flatten()?); + let room = Self::room(la, self.want(prev)).min(Self::room(lb, self.want(i + 1))); + if !room.is_finite() || room <= self.weld { + return None; + } + let (c1, c2) = (a.outward(false), b.outward(true)); + if a.is_line() && b.is_line() { + self.between_lines(&c1, &c2, room, (la, lb)) + } else { + self.beside_curve((&c1, a.is_line(), la), (&c2, b.is_line(), lb), room, angle) + } + } + + fn between_lines(&self, c1: &CubicBez, c2: &CubicBez, room: f64, (la, lb): (f64, f64)) -> Option { + let (u1, u2) = (start_dir(c1)?, start_dir(c2)?); + let curves = figcraft_geom::smoothed_corner(c1.p0, u1, u2, room, self.radius, self.smoothing)?; + let (first, last) = (curves.first()?, curves.last()?); + let (t_in, t_out) = (1.0 - (first.p0 - c1.p0).hypot() / la, (last.p3 - c1.p0).hypot() / lb); + (t_in.is_finite() && t_out.is_finite() && curves.iter().all(|c| [c.p0, c.p1, c.p2, c.p3].iter().all(|p| p.x.is_finite() && p.y.is_finite()))) + .then_some(Corner { curves, t_in, t_out }) + } + + /// One cubic between the cuts on both sides, its handles along each side's direction at its cut. + fn beside_curve( + &self, + (c1, line1, la): (&CubicBez, bool, f64), + (c2, line2, lb): (&CubicBez, bool, f64), + room: f64, + angle: f64, + ) -> Option { + let t = (self.radius / (angle / 2.0).tan()).min(room); + let ((p, d1, k1), (q, d2, k2)) = (trim(c1, t, line1, la)?, trim(c2, t, line2, lb)?); + let turn = (-d1.dot(d2)).clamp(-1.0, 1.0).acos(); + let chord = (q - p).hypot(); + let k = if turn < 1e-9 { chord / 3.0 } else { 4.0 / 3.0 * (turn / 4.0).tan() * chord / (2.0 * (turn / 2.0).sin()) }; + let ok = k.is_finite() && [p, q].iter().all(|x| x.x.is_finite() && x.y.is_finite()); + ok.then(|| Corner { curves: vec![CubicBez::new(p, p + d1 * k, q + d2 * k, q)], t_in: 1.0 - k1, t_out: k2 }) + } +} + +/// The subpath with its corners rounded, or `None` when no vertex of it is a corner that rounds +/// (the caller keeps the subpath exactly as it came in). +fn round_subpath(sub: &BezPath, radius: f64, smoothing: f64, weld: f64) -> Option { + let segs = clean(sub, weld); + let n = segs.len(); + if n < 2 { + return None; + } + let wants = wants(&segs, radius, smoothing); + let touches = |i: usize| wants.get(i).is_some_and(Option::is_some) || wants.get((i + 1) % n).is_some_and(Option::is_some); + let lens: Vec> = segs.iter().enumerate().map(|(i, s)| touches(i).then(|| s.len())).collect(); + let ctx = Ctx { segs: &segs, lens: &lens, wants: &wants, radius, smoothing, weld }; + let corners: Vec> = (0..n).map(|i| ctx.corner(i)).collect(); + corners.iter().any(Option::is_some).then(|| emit(&segs, &corners))? +} + +/// Walk the segments: what is left of each between the corner at its start and the one at its +/// end, then the next corner's curves. +fn emit(segs: &[Seg], corners: &[Option]) -> Option { + let n = segs.len(); + let mut out = BezPath::new(); + let first = segs.first()?; + out.move_to(corners.first().and_then(Option::as_ref).and_then(|c| c.curves.last()).map_or(first.start(), |c| c.p3)); + for (i, seg) in segs.iter().enumerate() { + let next = corners.get((i + 1) % n).and_then(Option::as_ref); + let t0 = corners.get(i).and_then(Option::as_ref).map_or(0.0, |c| c.t_out); + let t1 = next.map_or(1.0, |c| c.t_in); + if t1 - t0 > 1e-9 { + match seg { + Seg::Line(a, b) => out.line_to(a.lerp(*b, t1)), + Seg::Cubic(c) => { + let q = c.subsegment(t0..t1); + out.curve_to(q.p1, q.p2, q.p3); + } + } + } + for c in next.iter().flat_map(|c| c.curves.iter()) { + out.curve_to(c.p1, c.p2, c.p3); + } + } + out.close_path(); + crate::finite(&out).then_some(out) +} diff --git a/crates/pathops/src/round_tests.rs b/crates/pathops/src/round_tests.rs new file mode 100644 index 000000000..eda74b464 --- /dev/null +++ b/crates/pathops/src/round_tests.rs @@ -0,0 +1,238 @@ +//! `round_corners`: every sharp corner of a closed path, convex or concave, rounds with a circular +//! arc; smooth joins and straight-run leftovers of the sweep do not count; a radius larger than the +//! edges allow shrinks to fit. + +use figcraft_doc::BoolOp; +use figcraft_geom::kurbo::{Affine, BezPath, PathEl, PathSeg, Point, Shape, Vec2}; +use figcraft_geom::{ellipse, rounded_rect, split_subpaths}; + +use crate::{boolean, boolean_step, round_corners}; + +fn rect(x: f64, y: f64, w: f64, h: f64) -> BezPath { + kurbo_rect(x, y, w, h).to_path(0.1) +} + +fn kurbo_rect(x: f64, y: f64, w: f64, h: f64) -> figcraft_geom::Rect { + figcraft_geom::Rect::new(x, y, x + w, y + h) +} + +fn l_shape() -> BezPath { + boolean(&[rect(0.0, 0.0, 200.0, 100.0), rect(150.0, 0.0, 100.0, 200.0)], BoolOp::Union).unwrap() +} + +/// A (200 by 100, radius 10) with a 100 square cut out of its top edge: the notch of the measurement. +fn notch() -> BezPath { + let a = rounded_rect(200.0, 100.0, [10.0; 4], 0.0); + boolean_step(&a, &rect(50.0, -50.0, 100.0, 100.0), BoolOp::Subtract).unwrap() +} + +/// Every `CurveTo` as `[from, c1, c2, to]`. +fn arcs(path: &BezPath) -> Vec<[Point; 4]> { + let mut cur = Point::ZERO; + let mut out = Vec::new(); + for el in path.elements() { + match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => cur = p, + PathEl::CurveTo(a, b, c) => { + out.push([cur, a, b, c]); + cur = c; + } + _ => {} + } + } + out +} + +fn near(a: Point, b: Point) -> bool { + (a - b).hypot() < 1e-3 +} + +fn pt(x: f64, y: f64) -> Point { + Point::new(x, y) +} + +/// Some curve runs through exactly these four points, in either direction. +fn has_arc(path: &BezPath, want: [(f64, f64); 4]) -> bool { + let w = want.map(|(x, y)| pt(x, y)); + let rev = [w[3], w[2], w[1], w[0]]; + arcs(path).iter().any(|a| a.iter().zip(w).all(|(p, q)| near(*p, q)) || a.iter().zip(rev).all(|(p, q)| near(*p, q))) +} + +/// Some curve joins these two end points, in either direction. +fn has_arc_between(path: &BezPath, from: (f64, f64), to: (f64, f64)) -> bool { + let (f, t) = (pt(from.0, from.1), pt(to.0, to.1)); + arcs(path).iter().any(|a| (near(a[0], f) && near(a[3], t)) || (near(a[0], t) && near(a[3], f))) +} + +fn passes_through(path: &BezPath, p: Point) -> bool { + path.elements().iter().any(|el| match el { + PathEl::MoveTo(q) | PathEl::LineTo(q) | PathEl::CurveTo(_, _, q) => near(*q, p), + _ => false, + }) +} + +fn unit(v: Vec2) -> Option { + (v.hypot() > 1e-9).then(|| v / v.hypot()) +} + +fn start_dir(s: PathSeg) -> Option { + let c = s.to_cubic(); + [c.p1, c.p2, c.p3].into_iter().find_map(|p| unit(p - c.p0)) +} + +fn end_dir(s: PathSeg) -> Option { + let c = s.to_cubic(); + [c.p2, c.p1, c.p0].into_iter().find_map(|p| unit(c.p3 - p)) +} + +/// The largest direction change at any vertex of a closed subpath, in radians. +fn sharpest_turn(path: &BezPath) -> f64 { + let mut worst: f64 = 0.0; + for sub in split_subpaths(path) { + let segs: Vec = sub.segments().collect(); + for (i, s) in segs.iter().enumerate() { + let next = segs[(i + 1) % segs.len()]; + if let (Some(a), Some(b)) = (end_dir(*s), start_dir(next)) { + worst = worst.max(a.dot(b).clamp(-1.0, 1.0).acos()); + } + } + } + worst +} + +fn area(p: &BezPath) -> f64 { + p.area().abs() +} + +fn all_finite(p: &BezPath) -> bool { + crate::finite(p) +} + +#[test] +fn every_sharp_corner_of_the_result_rounds_convex_and_concave() { + let r = round_corners(&l_shape(), 20.0, 0.0); + assert_eq!(arcs(&r).len(), 6); + assert!(has_arc(&r, [(230.0, 0.0), (241.046, 0.0), (250.0, 8.95431), (250.0, 20.0)])); + assert!(has_arc(&r, [(150.0, 120.0), (150.0, 108.954), (141.046, 100.0), (130.0, 100.0)])); + assert!((area(&r) - (35000.0 - 4.0 * 85.84)).abs() < 0.5, "{}", area(&r)); + assert_eq!(r.winding(pt(1.0, 1.0)), 0); + assert_ne!(r.winding(pt(148.0, 102.0)), 0); + let bb = r.bounding_box(); + assert!((bb.width() - 250.0).abs() < 1e-6 && (bb.height() - 200.0).abs() < 1e-6); +} + +#[test] +fn a_childs_rounded_corners_are_kept_and_do_not_stack() { + let r = round_corners(¬ch(), 20.0, 0.0); + assert_eq!(arcs(&r).len(), 8); + assert!(has_arc(&r, [(30.0, 0.0), (41.0457, 0.0), (50.0, 8.954), (50.0, 20.0)])); + assert!(has_arc(&r, [(50.0, 30.0), (50.0, 41.0457), (58.954, 50.0), (70.0, 50.0)])); + assert!(has_arc_between(&r, (130.0, 50.0), (150.0, 30.0))); + assert!(has_arc_between(&r, (150.0, 20.0), (170.0, 0.0))); + assert!(has_arc(&r, [(0.0, 10.0), (0.0, 4.477), (4.477, 0.0), (10.0, 0.0)])); +} + +#[test] +fn a_radius_larger_than_the_edges_clamps_to_what_they_hold() { + let r = round_corners(¬ch(), 500.0, 0.0); + assert!(has_arc(&r, [(25.0, 0.0), (38.8071, 0.0), (50.0, 11.1929), (50.0, 25.0)])); + assert!(has_arc(&r, [(50.0, 25.0), (50.0, 38.807), (61.193, 50.0), (75.0, 50.0)])); + assert!(has_arc_between(&r, (125.0, 50.0), (150.0, 25.0))); + assert!(has_arc_between(&r, (150.0, 25.0), (175.0, 0.0))); + assert!(has_arc(&r, [(0.0, 10.0), (0.0, 4.477), (4.477, 0.0), (10.0, 0.0)])); + assert!(all_finite(&r)); + let l = round_corners(&l_shape(), 500.0, 0.0); + assert!(has_arc_between(&l, (0.0, 50.0), (50.0, 0.0))); + assert!(has_arc_between(&l, (150.0, 0.0), (250.0, 100.0))); + assert!((area(&round_corners(&l_shape(), 1e300, 0.0)) - area(&l)).abs() < 1e-6); +} + +#[test] +fn nothing_sharp_nothing_changes() { + let round = rounded_rect(100.0, 60.0, [20.0; 4], 0.0); + let oval = ellipse(100.0, 60.0, 0.0, std::f64::consts::TAU, 0.0); + assert_eq!(round_corners(&round, 20.0, 0.0), round); + assert_eq!(round_corners(&oval, 20.0, 0.0), oval); + let l = l_shape(); + for radius in [0.0, -5.0, f64::NAN, f64::INFINITY] { + assert_eq!(round_corners(&l, radius, 0.0), l); + } + let mut open = BezPath::new(); + open.move_to((0.0, 0.0)); + open.line_to((50.0, 0.0)); + open.line_to((50.0, 50.0)); + assert_eq!(round_corners(&open, 10.0, 0.0), open); +} + +#[test] +fn points_the_sweep_leaves_on_an_edge_are_not_corners() { + let mut p = BezPath::new(); + for (i, q) in [(0.0, 0.0), (20.0, 0.0), (100.0, 0.0), (100.0, 0.0), (100.0, 60.0), (100.0 + 1e-11, 60.0), (100.0, 100.0), (0.0, 100.0)] + .into_iter() + .enumerate() + { + if i == 0 { + p.move_to(q); + } else { + p.line_to(q); + } + } + p.close_path(); + let r = round_corners(&p, 500.0, 0.0); + assert_eq!(arcs(&r).len(), 4); + assert!(has_arc_between(&r, (0.0, 50.0), (50.0, 0.0))); +} + +fn two_circles(op: BoolOp) -> BezPath { + let c = ellipse(100.0, 100.0, 0.0, std::f64::consts::TAU, 0.0); + boolean(&[c.clone(), Affine::translate((70.0, 0.0)) * c], op).unwrap() +} + +#[test] +fn corners_beside_curves_round_too() { + for op in [BoolOp::Union, BoolOp::Intersect] { + let sharp = two_circles(op); + assert!(sharpest_turn(&sharp) > 1.0); + let r = round_corners(&sharp, 10.0, 0.0); + assert!(sharpest_turn(&r) < 1e-3, "{}", sharpest_turn(&r)); + assert!(all_finite(&r)); + match op { + BoolOp::Union => assert!(area(&r) > area(&sharp)), + _ => assert!(area(&r) < area(&sharp)), + } + } +} + +#[test] +fn smoothing_draws_the_polygon_profile_between_straight_edges() { + let l = l_shape(); + let plain = round_corners(&l, 20.0, 0.0); + let smooth = round_corners(&l, 20.0, 0.6); + assert!(passes_through(&smooth, pt(0.0, 32.0)) && passes_through(&smooth, pt(32.0, 0.0))); + assert!((area(&smooth) - area(&plain)).abs() > 1.0); + let circles = two_circles(BoolOp::Union); + assert_eq!(round_corners(&circles, 10.0, 0.6), round_corners(&circles, 10.0, 0.0)); +} + +#[test] +fn junk_never_panics() { + let mut bad = BezPath::new(); + bad.move_to((f64::NAN, 0.0)); + bad.line_to((10.0, f64::INFINITY)); + bad.close_path(); + assert_eq!(round_corners(&bad, 5.0, 0.0).elements().len(), 3); + assert!(round_corners(&BezPath::new(), 5.0, 0.0).elements().is_empty()); + let mut one = BezPath::new(); + one.move_to((0.0, 0.0)); + one.line_to((10.0, 0.0)); + one.close_path(); + assert!(all_finite(&round_corners(&one, 5.0, 0.5))); + assert!(all_finite(&round_corners(&rect(0.0, 0.0, 1.0, 0.5), 0.1, 0.0))); + let mut zig = BezPath::new(); + zig.move_to((0.0, 0.0)); + for i in 1..40_000 { + zig.line_to((f64::from(i) * 0.5, f64::from(i % 2))); + } + zig.close_path(); + assert!(all_finite(&round_corners(&zig, 2.0, 0.0))); +} From 529134b850232b1abe92b5b7354f39a30fb04c12 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 10 Nov 2026 13:36:10 -0500 Subject: [PATCH 13/17] M4: a boolean group's corner radius rounds its result (#2222) A boolean group takes a corner radius (and corner smoothing): the Design panel shows the field for it, `node.set cornerRadius` writes it, and the renderer rounds every sharp corner of the group's result after the fold, convex and concave alike. Layers inside keep their own radius and do not stack with it, a radius larger than the edges allow shrinks to fit while the field keeps the typed value, Flatten bakes the arcs, and a mixed selection reads Mixed. Measured in Figma's web app on 2026-11-10. The rounded result is cached apart from the fold (keyed by the folded path, radius and smoothing), so scrubbing the radius never redoes the boolean steps. The .fig and REST importers and the .fig exporter now carry the radius and smoothing of a boolean group, and paste properties and radius variables reach it. Docs: ui-parity, target-app-parity, gaps, file-format-parity, ROADMAP, mcp. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Fable 5.1 designed; Sonnet 5.5 wrote at medium --- ROADMAP.md | 3 +- crates/doc/src/node.rs | 12 +- crates/engine/src/boolean_radius_tests.rs | 134 ++++++++++++++++++++ crates/engine/src/booleans_tests.rs | 8 +- crates/engine/src/lib.rs | 2 + crates/engine/src/shapes_behaviour_tests.rs | 2 + crates/format/src/fig/export_tests.rs | 15 +++ crates/format/src/rest/tests.rs | 4 + crates/render/src/boolean.rs | 29 +++++ crates/render/src/boolean_cache_tests.rs | 6 +- crates/render/src/boolean_radius_tests.rs | 60 +++++++++ crates/render/src/draw.rs | 5 +- crates/render/src/lib.rs | 2 + crates/ui-egui/src/field_tests.rs | 19 +++ crates/ui-egui/src/panels/right/paints.rs | 9 +- docs/file-format-parity.md | 5 +- docs/gaps.md | 9 +- docs/mcp.md | 2 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 5 +- 20 files changed, 309 insertions(+), 27 deletions(-) create mode 100644 crates/engine/src/boolean_radius_tests.rs create mode 100644 crates/render/src/boolean_radius_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index dc6204b44..5d53c2ff1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** patch (the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; 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 Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** patch (a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; 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 Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -365,6 +365,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | M4, #2222: a boolean group takes a corner radius that rounds every sharp corner of its result. Numbers unchanged | | 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`, 繁體中文(台灣)) draft catalog: every UI string (1,975), command label (641) and menu wording (31), translated clean-room from the English source; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant` read it while an explicit Hans stays Simplified; the desktop UI prefers an installed Noto Sans CJK TC, then Microsoft JhengHei, PingFang TC and Heiti TC; no font is bundled; native review pending. Numbers unchanged | | 2026-11-06 | patch | M1, #2028: Edit ▸ Set default properties gives new rectangles, ellipses, polygons and stars drawn with the tools (and, set from a text layer, new text) the selected layer's properties; it only copied them for Paste properties. Tests: `default_props_tests` (3). Numbers unchanged | | 2026-11-06 | patch | M5, #2027: `proto.settings` refuses an unknown device name with the list of devices (it saved any string, and the presenter showed no device), takes known names in any case, and answers the page's prototype settings, so `proto.settings {}` reads them back over MCP. Test: `proto_present_tests::unknown_devices_are_refused_and_settings_read_back`. Numbers unchanged | diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 5b90b3bff..f9601f362 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -395,7 +395,8 @@ impl NodeKind { ) } - /// Kinds whose corners can be rounded from the panel. + /// Kinds whose corners can be rounded from the panel. A boolean group has one radius for every + /// sharp corner of its result. pub fn has_corner_radius(&self) -> bool { matches!( self, @@ -408,16 +409,19 @@ impl NodeKind { | NodeKind::Star { .. } | NodeKind::Vector { .. } | NodeKind::Section { .. } + | NodeKind::BooleanOp { .. } ) } - /// Kinds with a radius per corner (the others round every corner alike). + /// Kinds with a radius per corner (the others, boolean groups included, round every corner alike). pub fn has_independent_corners(&self) -> bool { - self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. }) + self.has_corner_radius() + && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. } | NodeKind::BooleanOp { .. }) } /// Kinds whose rounded corners are drawn with corner smoothing: a vector only when its network - /// has a closed region (an open path's rounded points are drawn without it). + /// has a closed region (an open path's rounded points are drawn without it). A boolean group's + /// smoothing applies to its result's corners between straight edges. pub fn has_corner_smoothing(&self) -> bool { match self { NodeKind::Vector { network } => !network.regions.is_empty(), diff --git a/crates/engine/src/boolean_radius_tests.rs b/crates/engine/src/boolean_radius_tests.rs new file mode 100644 index 000000000..f739b3eaf --- /dev/null +++ b/crates/engine/src/boolean_radius_tests.rs @@ -0,0 +1,134 @@ +//! A boolean group's corner radius through the engine: it rounds the result (and the SVG export +//! of it), children keep their own radius, Flatten bakes the arcs and clicks follow the result. + +use serde_json::json; + +use crate::Session; +use crate::booleans_tests::{area, click, ex}; +use crate::params::id_of; +use figcraft_doc::{NodeId, NodeKind}; +use figcraft_geom::{Point, Shape}; + +const L_AREA: f64 = 34656.6; + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"]).unwrap() +} + +fn snapping_off(s: &mut Session) { + ex(s, "select.none", json!({})); + let ds = s.ds_mut().unwrap(); + ds.view.snap_objects = false; + ds.view.snap_pixel = false; +} + +fn set_radius(s: &mut Session, id: NodeId, r: f64) { + ex(s, "node.set", json!({"ids": [id], "props": {"cornerRadius": r}})); +} + +/// The L of two rectangles, united, nothing selected, snapping off. +fn l_group(s: &mut Session) -> NodeId { + let (a, b) = (rect(s, 0.0, 0.0, 200.0, 100.0), rect(s, 150.0, 0.0, 100.0, 200.0)); + let g = id_of(&ex(s, "bool.union", json!({"ids": [a, b]}))["id"]).unwrap(); + snapping_off(s); + g +} + +/// Whether the page point is inside the group's drawn result. +fn covers(s: &Session, g: NodeId, x: f64, y: f64) -> bool { + let doc = s.doc().unwrap(); + let (path, _) = figcraft_render::node_outline(doc, doc.node(g).unwrap()).unwrap(); + let p = doc.world_transform(g).inverse() * Point::new(x, y); + path.winding(p) % 2 != 0 +} + +#[test] +fn a_boolean_group_rounds_every_sharp_corner_of_its_result() { + let mut s = Session::new(); + let g = l_group(&mut s); + assert!(covers(&s, g, 1.0, 1.0)); + set_radius(&mut s, g, 20.0); + assert!(!covers(&s, g, 1.0, 1.0) && covers(&s, g, 148.0, 102.0)); + assert!((area(&s, g) - L_AREA).abs() < 1.0, "{}", area(&s, g)); + let doc = s.doc().unwrap(); + let svg = figcraft_svg::export_svg(doc, &[g], &figcraft_svg::SvgOptions::default()).unwrap(); + let d = svg.split("d=\"").nth(1).and_then(|r| r.split('"').next()).unwrap(); + let exported = figcraft_geom::svg_path(d).unwrap(); + assert!((exported.area().abs() - L_AREA).abs() < 1.0, "{}", exported.area()); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap().node(g).unwrap().corners.radii, [0.0; 4], "one undo step"); + assert!((area(&s, g) - 35000.0).abs() < 1.0); +} + +/// The notch of the measurement: A (200 by 100 at the origin) minus a 100 square on its top edge, +/// whose own radius is `a_radius`, under a group radius of `radius`. +fn notch(s: &mut Session, a: NodeId, radius: f64) -> NodeId { + let b = rect(s, 50.0, -50.0, 100.0, 100.0); + let g = id_of(&ex(s, "bool.subtract", json!({"ids": [a, b]}))["id"]).unwrap(); + snapping_off(s); + set_radius(s, g, radius); + g +} + +/// The asserts of the measurement on a notched group at radius 20 under A's own 10. +fn assert_notch_at_20(s: &Session, g: NodeId) { + assert!(covers(s, g, 4.0, 4.0), "A's 10 was not enlarged to 20"); + assert!(!covers(s, g, 48.0, 2.0), "the cut's top corner rounds"); + assert!(covers(s, g, 52.0, 48.0), "the cut's inner corner fills in"); +} + +#[test] +fn children_keep_their_radius_and_a_subtraction_rounds_its_cut() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0, 200.0, 100.0); + set_radius(&mut s, a, 10.0); + let g = notch(&mut s, a, 20.0); + assert_notch_at_20(&s, g); + assert_eq!(s.doc().unwrap().node(a).unwrap().corners.radii, [10.0; 4]); + set_radius(&mut s, g, 500.0); + assert_eq!(s.doc().unwrap().node(g).unwrap().corners.radii[0], 500.0, "the field keeps the value"); + assert!(!covers(&s, g, 40.0, 4.0), "25, not the 20 that half the 40-long edge would give"); + assert!(covers(&s, g, 27.0, 1.0), "and not more than 25"); + assert!(covers(&s, g, 100.0, 51.0) && !covers(&s, g, 100.0, 49.0), "the floor stays straight between 75 and 125"); + assert!(covers(&s, g, 4.0, 4.0)); +} + +#[test] +fn a_nested_group_keeps_its_own_radius() { + let mut s = Session::new(); + let (l, r) = (rect(&mut s, 0.0, 0.0, 120.0, 100.0), rect(&mut s, 80.0, 0.0, 120.0, 100.0)); + let inner = id_of(&ex(&mut s, "bool.union", json!({"ids": [l, r]}))["id"]).unwrap(); + set_radius(&mut s, inner, 10.0); + let g = notch(&mut s, inner, 20.0); + assert_notch_at_20(&s, g); +} + +#[test] +fn flatten_bakes_the_arcs_and_the_vector_has_no_radius() { + let mut s = Session::new(); + let g = l_group(&mut s); + set_radius(&mut s, g, 20.0); + let flat = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [g]}))["id"]).unwrap(); + let doc = s.doc().unwrap(); + let n = doc.node(flat).unwrap(); + let NodeKind::Vector { network } = &n.kind else { panic!("a vector: {:?}", n.kind) }; + assert_eq!(n.corners.radii, [0.0; 4]); + assert!(network.vertices.iter().all(|v| v.radius == 0.0)); + let path = figcraft_doc::geometry::shape_path(n).unwrap(); + assert!((path.area().abs() - L_AREA).abs() < 1.0, "{}", path.area()); + assert!((n.width - 250.0).abs() < 1e-6 && (n.height - 200.0).abs() < 1e-6); +} + +#[test] +fn clicks_follow_the_rounded_result() { + let mut s = Session::new(); + let g = l_group(&mut s); + set_radius(&mut s, g, 20.0); + click(&mut s, 1.0, 1.0, false); + assert!(s.selection().is_empty(), "the cut-off corner reaches what is behind"); + click(&mut s, 148.0, 102.0, false); + assert_eq!(s.selection(), vec![g], "the concave fillet belongs to the group"); + ex(&mut s, "select.none", json!({})); + click(&mut s, 100.0, 50.0, false); + assert_eq!(s.selection(), vec![g]); +} diff --git a/crates/engine/src/booleans_tests.rs b/crates/engine/src/booleans_tests.rs index 068719588..d29c28127 100644 --- a/crates/engine/src/booleans_tests.rs +++ b/crates/engine/src/booleans_tests.rs @@ -11,21 +11,21 @@ use crate::tools::{PointerEvent, PointerKind}; use figcraft_doc::{BoolOp, NodeId, NodeKind}; use figcraft_geom::Shape; -fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { +pub(crate) fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } -fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent { +pub(crate) fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent { PointerEvent { cmd, ..PointerEvent::at(kind, x, y, 1.0) } } -fn click(s: &mut Session, x: f64, y: f64, cmd: bool) { +pub(crate) fn click(s: &mut Session, x: f64, y: f64, cmd: bool) { s.pointer(&ev(PointerKind::Down, x, y, cmd)).unwrap(); s.pointer(&ev(PointerKind::Up, x, y, cmd)).unwrap(); } /// A layer's filled area (a boolean group's result; none of these have holes). -fn area(s: &Session, id: NodeId) -> f64 { +pub(crate) fn area(s: &Session, id: NodeId) -> f64 { let doc = s.doc().unwrap(); figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap().0.area().abs() } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 302e7053e..e0093ff39 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -225,6 +225,8 @@ mod board_tools_tests; #[cfg(test)] mod boolean_cap_tests; #[cfg(test)] +mod boolean_radius_tests; +#[cfg(test)] mod booleans_tests; #[cfg(test)] mod bound_alpha_tests; diff --git a/crates/engine/src/shapes_behaviour_tests.rs b/crates/engine/src/shapes_behaviour_tests.rs index ca2d16390..39cf2c29b 100644 --- a/crates/engine/src/shapes_behaviour_tests.rs +++ b/crates/engine/src/shapes_behaviour_tests.rs @@ -189,6 +189,7 @@ fn corner_radius_follows_the_documented_rules() { (NodeKind::Polygon { count: 5 }, true, false, "polygons: one radius for every corner"), (NodeKind::Star { count: 5, inner_ratio: 0.4 }, true, false, "stars: one radius for every corner"), (NodeKind::Vector { network: Default::default() }, true, false, "vectors: a radius, and one per point in edit mode"), + (NodeKind::BooleanOp { op: BoolOp::Union }, true, false, "boolean groups: one radius for every sharp corner of the result"), (NodeKind::Line, false, false, "lines and arrows take no radius"), ]; for (kind, radius, independent, why) in doc_rows { @@ -241,6 +242,7 @@ fn corner_smoothing_follows_the_documented_rules() { "vector networks that are closed shapes", ), (NodeKind::Vector { network: Default::default() }, false, "an open vector has no closed shape to smooth"), + (NodeKind::BooleanOp { op: BoolOp::Union }, true, "boolean groups (the plugin API's corner mixin; the look is pinned)"), (NodeKind::Line, false, "lines take no radius, so no smoothing"), ]; for (kind, smoothing, why) in doc_rows { diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 9651a1025..f1ae67a4b 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -811,6 +811,21 @@ fn generated_documents_round_trip() { } } +#[test] +fn a_boolean_groups_corner_radius_round_trips() { + let mut doc = Document::new("t"); + let page = doc.first_page().unwrap(); + let g = doc.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "G", 0.0, 0.0, 0.0, 0.0).unwrap(); + doc.create(g, NodeKind::Rectangle, "A", 0.0, 0.0, 200.0, 100.0).unwrap(); + doc.create(g, NodeKind::Rectangle, "B", 150.0, 0.0, 100.0, 200.0).unwrap(); + doc.fit_to_children(g).unwrap(); + doc.get_mut(g).unwrap().corners = Corners { radii: [20.0; 4], smoothing: 0.6 }; + let (back, _) = round_trip(&doc, "boolean radius"); + let n = back.nodes().find(|n| matches!(n.kind, NodeKind::BooleanOp { .. })).unwrap(); + assert!(n.corners.radii.iter().all(|r| (r - 20.0).abs() < 1e-4), "{:?}", n.corners); + assert!((n.corners.smoothing - 0.6).abs() < 1e-4, "{:?}", n.corners); +} + #[test] fn round_trip_keeps_the_details() { let doc = generate(3); diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 3ea88fad1..6dc710979 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -290,6 +290,8 @@ fn vectors_ellipses_stars_and_booleans() { counted["pointCount"] = json!(6); let mut boolean = base("Bool", "BOOLEAN_OPERATION"); boolean["booleanOperation"] = json!("SUBTRACT"); + boolean["cornerRadius"] = json!(12); + boolean["cornerSmoothing"] = json!(0.6); boolean["children"] = json!([base("A", "RECTANGLE"), {"id": "B", "name": "B", "type": "ELLIPSE", "relativeTransform": at(5.0, 5.0), "size": {"x": 10, "y": 10}}]); let (doc, warnings) = one(json!([tri, wire, empty, arc, star, plain_star, counted, boolean])); @@ -311,6 +313,8 @@ fn vectors_ellipses_stars_and_booleans() { assert!(matches!(b.kind, NodeKind::BooleanOp { op: BoolOp::Subtract })); assert_eq!(b.children.len(), 2); assert!(close(b.width, 15.0), "booleans wrap their children"); + assert_eq!(b.corners.radii, [12.0; 4], "a boolean group keeps its corner radius"); + assert!(close(b.corners.smoothing, 0.6)); } #[test] diff --git a/crates/render/src/boolean.rs b/crates/render/src/boolean.rs index 7fc642082..039a3967b 100644 --- a/crates/render/src/boolean.rs +++ b/crates/render/src/boolean.rs @@ -7,6 +7,11 @@ //! edited one are never recombined, and groups whose operands didn't change aren't recomputed at //! all. The caches drop their least recently used half when full, so a long drag doesn't evict //! every other group's result. +//! +//! The group's own corner radius is applied to the finished fold ([`result_cached`]) and cached in a +//! separate, smaller map keyed by the folded path plus the radius and smoothing: the fold's caches +//! are keyed by the operands alone, so scrubbing the radius never redoes the fold or evicts its +//! prefixes, and a rounded path is never stored where a fold would find it. use std::collections::HashMap; use std::hash::{DefaultHasher, Hash, Hasher}; @@ -109,6 +114,30 @@ pub fn fold_cached(operands: &[(BezPath, BoolOp)]) -> Option { } } +fn rounded() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(PathCache::new(512))) +} + +/// A boolean group's result as it is drawn: the fold of `operands` ([`fold_cached`]), then the +/// group's corner `radius` and `smoothing` on every corner still sharp. +pub fn result_cached(operands: &[(BezPath, BoolOp)], radius: f64, smoothing: f64) -> Option { + let folded = fold_cached(operands)?; + if !(radius.is_finite() && radius > 0.0) { + return Some(folded); + } + let mut h = DefaultHasher::new(); + hash_path(&folded, &mut h); + (radius.to_bits(), smoothing.to_bits(), 0x52u8).hash(&mut h); + let key = h.finish(); + if let Some(p) = locked(rounded()).get(key) { + return Some((*p).clone()); + } + let out = figcraft_pathops::round_corners(&folded, radius, smoothing); + locked(rounded()).insert(key, Arc::new(out.clone())); + Some(out) +} + /// `keys[i]`: the cache key of the fold of operands `0..=i`. fn fold_keys(operands: &[(BezPath, BoolOp)]) -> Vec { let mut h = DefaultHasher::new(); diff --git a/crates/render/src/boolean_cache_tests.rs b/crates/render/src/boolean_cache_tests.rs index 7a34bf4f2..51e422425 100644 --- a/crates/render/src/boolean_cache_tests.rs +++ b/crates/render/src/boolean_cache_tests.rs @@ -7,12 +7,12 @@ use figcraft_color::{Color, Paint}; use figcraft_doc::{BoolOp, Document, NodeId, NodeKind, WidthPoint, WidthProfile}; use figcraft_geom::kurbo::{BezPath, Shape}; -fn dark(p: &Pixmap, x: u32, y: u32) -> bool { +pub(crate) fn dark(p: &Pixmap, x: u32, y: u32) -> bool { let i = ((y * p.width() + x) * 4) as usize; p.data().get(i).is_some_and(|r| *r < 40) } -fn render(d: &Document, p: NodeId) -> Pixmap { +pub(crate) fn render(d: &Document, p: NodeId) -> Pixmap { render_view(d, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 200, height: 200 }, &Options::default()).unwrap() } @@ -28,7 +28,7 @@ fn row_of_squares(op: BoolOp, n: usize) -> (Document, NodeId, NodeId, Vec BezPath { +pub(crate) fn outline(d: &Document, id: NodeId) -> BezPath { node_outline(d, d.node(id).unwrap()).unwrap().0 } diff --git a/crates/render/src/boolean_radius_tests.rs b/crates/render/src/boolean_radius_tests.rs new file mode 100644 index 000000000..dfe98af03 --- /dev/null +++ b/crates/render/src/boolean_radius_tests.rs @@ -0,0 +1,60 @@ +//! A boolean group's corner radius: it rounds the result the canvas draws, and keys a cache of its +//! own so the fold underneath is not redone. + +use crate::boolean::boolean_steps; +use crate::boolean_cache_tests::{dark, outline, render}; +use figcraft_color::{Color, Paint}; +use figcraft_doc::{BoolOp, Document, NodeId, NodeKind}; +use figcraft_geom::kurbo::Shape; + +/// The L of the measurement: a union of 200 by 100 and 100 by 200 rectangles, on a page of its own. +fn l_group() -> (Document, NodeId, NodeId, NodeId) { + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let g = d.create(p, NodeKind::BooleanOp { op: BoolOp::Union }, "G", 0.0, 0.0, 0.0, 0.0).unwrap(); + d.get_mut(g).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + d.create(g, NodeKind::Rectangle, "A", 0.0, 0.0, 200.0, 100.0).unwrap(); + let b = d.create(g, NodeKind::Rectangle, "B", 150.0, 0.0, 100.0, 200.0).unwrap(); + d.fit_to_children(g).unwrap(); + (d, p, g, b) +} + +fn set_radius(d: &mut Document, g: NodeId, r: f64) { + d.get_mut(g).unwrap().corners.radii = [r; 4]; +} + +fn area(d: &Document, g: NodeId) -> f64 { + outline(d, g).area().abs() +} + +#[test] +fn a_boolean_groups_radius_rounds_what_the_canvas_draws() { + let (mut d, p, g, _) = l_group(); + let pix = render(&d, p); + assert!(dark(&pix, 1, 1) && !dark(&pix, 148, 102)); + set_radius(&mut d, g, 20.0); + let pix = render(&d, p); + assert!(!dark(&pix, 1, 1), "the corner is cut off"); + assert!(dark(&pix, 148, 102), "the concave corner is filled in"); + assert!(dark(&pix, 100, 50)); + assert!((area(&d, g) - 34656.6).abs() < 1.0, "{}", area(&d, g)); +} + +#[test] +fn the_radius_keys_the_result_and_the_fold_is_not_redone() { + let (mut d, _, g, b) = l_group(); + let sharp = outline(&d, g); + set_radius(&mut d, g, 20.0); + let _ = outline(&d, g); + let before = boolean_steps(); + set_radius(&mut d, g, 40.0); + // Four net corners of 343.36 each, plus the cubic arcs' own slight excess over a circle. + assert!((area(&d, g) - 33626.6).abs() < 2.0, "{}", area(&d, g)); + assert_eq!(boolean_steps(), before, "a new radius reuses the fold"); + set_radius(&mut d, g, 0.0); + assert_eq!(outline(&d, g), sharp, "radius 0 draws the fold itself"); + set_radius(&mut d, g, 20.0); + let rounded = area(&d, g); + d.get_mut(b).unwrap().transform = figcraft_geom::Affine::translate((10.0, 0.0)) * d.node(b).unwrap().transform; + assert!((area(&d, g) - rounded).abs() > 1.0, "moving an operand does not draw a stale rounded path"); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index ff3a06270..a4e7db670 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -326,8 +326,9 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F Some((p, c.bool_op.unwrap_or(*op))) }) .collect(); - // Past the path operations' caps the group has no outline: it draws without its result. - crate::boolean::fold_cached(&operands).map(|p| (p, FillRule::EvenOdd)) + // Past the path operations' caps the group has no outline: it draws without its result. The + // group's corner radius rounds what is still sharp in the fold. + crate::boolean::result_cached(&operands, n.corners.radii[0], n.corners.smoothing).map(|p| (p, FillRule::EvenOdd)) } NodeKind::Text(_) => { let (l, dy) = figcraft_text::layout_node(n)?; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f138521ae..9d5b1618a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -518,6 +518,8 @@ mod boolean_budget_tests; #[cfg(test)] mod boolean_cache_tests; #[cfg(test)] +mod boolean_radius_tests; +#[cfg(test)] mod bounds_memo_tests; #[cfg(test)] mod dots_tests; diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index e3bdad1dd..4322c8808 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -1403,3 +1403,22 @@ fn alt_held_over_a_field_scrubs_it() { u.frame(vec![]); assert_eq!(geom(&u, a).0, 65.0, "ten more"); } + +#[test] +fn a_boolean_group_has_one_corner_radius_field_and_a_mixed_selection_reads_mixed() { + let (mut u, a, b) = setup(); + let g = id(u.app.session.execute("bool.union", &json!({"ids": [a, b]})).unwrap()); + let r = id(u.app.session.execute("shape.rectangle", &json!({"x": 400, "y": 0, "width": 50, "height": 50})).unwrap()); + u.app.run("select.set", json!({"ids": [g]})); + assert_eq!(field(&mut u, "Corner radius").1, "0"); + let steps = undo_steps(&u); + type_into(&mut u, "Corner radius", "20", Key::Enter); + let radii = |u: &Ui, n: NodeId| u.app.session.doc().unwrap().node(n).unwrap().corners.radii; + assert_eq!(radii(&u, g), [20.0; 4]); + assert_eq!(undo_steps(&u), steps + 1, "one undo step"); + assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "no field per corner"); + u.app.run("select.set", json!({"ids": [g, r]})); + assert_eq!(field(&mut u, "Corner radius").1, "Mixed"); + type_into(&mut u, "Corner radius", "8", Key::Enter); + assert_eq!((radii(&u, g), radii(&u, r)), ([8.0; 4], [8.0; 4])); +} diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index 967ae266b..157c65731 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -51,9 +51,11 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N let op_of = |n: &Node| (f64::from(n.opacity) * 100.0).round(); let op = common(doc, sel, op_of); let radius_ok = crate::selection_cache::every(doc, sel, |n| n.kind.has_corner_radius()); - // Only rectangles and frames round each corner on its own. + // Only rectangles and frames round each corner on its own; a boolean group has one radius for + // every sharp corner of its result. let indep_ok = radius_ok && crate::selection_cache::every(doc, sel, |n| n.kind.has_independent_corners()); - // For polygons, stars and closed vectors the same panel holds only the Corner smoothing row. + // For polygons, stars, boolean groups and closed vectors the same panel holds only the Corner + // smoothing row. let smooth_ok = radius_ok && crate::selection_cache::every(doc, sel, |n| n.kind.has_corner_smoothing()); let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0)); widgets::sublabels(ui, tr("Opacity"), radius_ok.then_some(tr("Corner radius"))); @@ -77,7 +79,8 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N let radius_of = |n: &Node| n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max)); app.field_of(&ids, "cornerRadius", e, radius_of, |v| json!(v.max(0.0))); let open = ui.data(|d| d.get_temp::(Id::unique("indep_corners"))).unwrap_or(false); - // Polygons and stars have no corners of their own: the button opens only Corner smoothing. + // Polygons, stars and boolean groups have no corners of their own: the button opens only + // Corner smoothing. let tip = if indep_ok { tr("Individual corners") } else { tr("Corner smoothing") }; if smooth_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tip).clicked() { ui.data_mut(|d| d.insert_temp(Id::unique("indep_corners"), !open)); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index dd04fdf75..c04d8f7bf 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (`.fig` import scales per-point vector corner radii with the row that supplies their network and keeps different instance scales separate in the network cache (#2097); `.fig` export preserves scaled-instance lengths reset to their unscaled main values, including nested linked-style overrides (#2097); `.fig` import: the layers of an instance scaled with the Scale tool draw at its scale factor, not at their main's lengths (#2091); a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (`.fig` import and export read and write a boolean group's corner radius and smoothing (#2222); `.fig` import scales per-point vector corner radii with the row that supplies their network and keeps different instance scales separate in the network cache (#2097); `.fig` export preserves scaled-instance lengths reset to their unscaled main values, including nested linked-style overrides (#2097); `.fig` import: the layers of an instance scaled with the Scale tool draw at its scale factor, not at their main's lengths (#2091); a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides; an instance scaled with the Scale tool, which stores `symbolData.uniformScaleFactor`, draws its layers at the main's lengths times that factor, composed through nested instances: sizes and places, corner radii, stroke weights and dashes, effect radii, spreads and offsets, auto layout gaps and paddings, grid gaps, min and max sizes, layout grids, font sizes, pixel line heights and letter spacings, paragraph spacing and indent, except where an override or derived row gives the value, which is already at the instance's scale; per-point vector corner radii also take their vector-data row's factor; the vector outline paths a layer carries (a network's points follow the layer's size), fixed grid tracks, image tile scales and glass effects are not scaled by the factor, #2091), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234), and the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (four decoded values per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows the file's size too), so a 15.4 MB synthetic file with 300,000 layers opens whole; ZIP image reads have a separate total budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, with the 256 MiB per-image cap kept: a member that cannot fit is skipped before its output buffer is allocated, and the import report names the budget and counts skipped images (#1076). Failed reads and invalid image contents still consume the bytes read; a reference used by several layers is read once. This is not a limit on decoded pixels or total import memory; images carried inside the message are unchanged. The file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Elbowed and curved connectors without a saved path are routed as FigCraft's board connectors are (right angles, or one curve, from the sides of the layers they join); an elbowed one saved with its path (`connectorControlPoints`: a point and an axis per run between the first and the last) keeps that path when it leaves and arrives by the ends' sides (#1910), and a curved one's saved `connectorBezierControlPoints` are not read; a connector routed here takes its line's paint, weight, cap and dashes from the file's generated line record, as one with a saved snapshot does (#1807). A connector's label without a saved snapshot is kept as text centred on its line, without its background. Not read: table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations (with their corner radius and smoothing, #2222), components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides; an instance scaled with the Scale tool, which stores `symbolData.uniformScaleFactor`, draws its layers at the main's lengths times that factor, composed through nested instances: sizes and places, corner radii, stroke weights and dashes, effect radii, spreads and offsets, auto layout gaps and paddings, grid gaps, min and max sizes, layout grids, font sizes, pixel line heights and letter spacings, paragraph spacing and indent, except where an override or derived row gives the value, which is already at the instance's scale; per-point vector corner radii also take their vector-data row's factor; the vector outline paths a layer carries (a network's points follow the layer's size), fixed grid tracks, image tile scales and glass effects are not scaled by the factor, #2091), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234), and the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (four decoded values per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows the file's size too), so a 15.4 MB synthetic file with 300,000 layers opens whole; ZIP image reads have a separate total budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, with the 256 MiB per-image cap kept: a member that cannot fit is skipped before its output buffer is allocated, and the import report names the budget and counts skipped images (#1076). Failed reads and invalid image contents still consume the bytes read; a reference used by several layers is read once. This is not a limit on decoded pixels or total import memory; images carried inside the message are unchanged. The file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Elbowed and curved connectors without a saved path are routed as FigCraft's board connectors are (right angles, or one curve, from the sides of the layers they join); an elbowed one saved with its path (`connectorControlPoints`: a point and an axis per run between the first and the last) keeps that path when it leaves and arrives by the ends' sides (#1910), and a curved one's saved `connectorBezierControlPoints` are not read; a connector routed here takes its line's paint, weight, cap and dashes from the file's generated line record, as one with a saved snapshot does (#1807). A connector's label without a saved snapshot is kept as text centred on its line, without its background. Not read: table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | | `.penpot` (Penpot 2.x ZIP export) | — | **yes, offline** (open, import, drop, CLI, MCP) | **yes, offline** (export, CLI convert) | Editable layers, supported flex/grid layout, components, tokens and prototypes using the existing model. Unsupported layouts retain saved positions and bounded metadata; toggle-overlay and embedded font binaries are reported. Font references remain editable; no downloads. Synthetic regression coverage; live Penpot and cross-platform QA remain open. See [Penpot reference](penpot.md). | further live Penpot and cross-platform QA | | `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, stretching filling every item across, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides; from Sketch 64, before which a group's fill draws nothing), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); operands' own borders ignored as Sketch does; open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (their image under their style's fills; colour adjustments as image filters with Sketch's tone curve, contrast and then brightness added), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, gradients mixed in OKLab or OKLCH as sRGB stops along the same colours, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer, motion and background blurs (Sketch's radius is the standard deviation), opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does, and its opacity fades it and what it clips together; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, also on shapes' image fills, layer and text styles, nested paths), clipping only where the master clips (`clippingBehavior` 1), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only when the master includes them in both instances and exports, as on Sketch's canvas and renders; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open, an auto-width one growing from its aligned edge. Warnings: noise fills, OKLab and OKLCH gradients, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%); the rules for master backgrounds, symbol clipping, mask opacity, bitmap fills, image overrides, stretching stacks, blur radii, colour adjustments, gradient interpolation and text growth checked against Sketch's renders of files built for each case (black-box, synthetic) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session). Known limitations: a plain group (not a frame) that fills a stack keeps its layers' size where Sketch stretches them; a font Sketch can't find draws in the installed family's own weight (Sketch falls back to a regular face); text in the system SF Pro comes out a few percent narrower than Sketch's; macOS's reserved system fonts (PingFang) aren't found; brightness past about +0.4 is approximated (image filters can't add); OKLab gradients become sRGB stops (FigCraft gradients mix in sRGB) | | `.jam` (whiteboards) | native to FigJam | **yes, best effort** (`file.open`, `file.import`, File ▸ Open, drop, CLI, MCP) | no | the same archive as a `.fig` (its canvas header reads `fig-jam.`) through the same reader (#1342): it opens as a board (the file kind; a `.fig` stays a design file), with generated sticky and shape backgrounds and text kept (#1341) as ordinary layers rather than native board objects (see [boards-parity](boards-parity.md)); tables, stamps, code blocks and the like come in as frames with their text and connectors as vectors (an elbowed connector keeps the path it was saved with, #1910), with an import note; a layer type the reader doesn't know is left out with a note naming it; writing `.jam` is not supported | — | @@ -196,6 +196,7 @@ scales and empty-style clears; they have not been checked in other software. | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | M4, #2222: a boolean group's corner radius and smoothing are read and written by the `.fig` importer and exporter, and by the REST importer | | 2026-11-08 | minor | M6, #2097 follow-up: per-point vector corner radii take the scale of the row supplying `vectorData`, including nested instance rows; override and derived rows are not scaled twice. Network caching distinguishes the instance scale even when resizing gives two vectors the same final box. Synthetic tests cover missing/unit factors, half/double scale, derived size alone, overridden and derived vector data, nested scale and equal-size cache separation. Layer-radius fallback remains the separate #2115 change. Numbers unchanged. | | 2026-11-08 | patch | #2097 / #2100: scaled-instance lengths reset to the unscaled main value survive export/import as explicit overrides. Dimensionless values and zero do not gain scale overrides. Exported factors stay within the importer's 0.001–1000 range; larger or smaller ratios use explicit values instead. Effect/grid payload rows above an inherited style link take precedence at their own scale, including nested instances and empty-list clears, without detaching styles. Synthetic regressions cover corners, dashes/shadows, layout/grids and bounds, typography and styled runs, linked text and nested styles. Numbers unchanged. | | 2026-11-07 | minor | `.fig` import, #2091 item 1: an instance scaled with the Scale tool stores `symbolData.uniformScaleFactor`, which the import never read, so the layers of a 128 px instance of a 256 px component came in at 256 px with the main's radii, strokes and spacing. The layers of an instance now take the main's lengths times the factor (composed through nested instances, so 0.5 inside 0.5 is 0.25 for the inner layers), the lengths the Scale tool scales: sizes and places, corner radii, stroke weights and dashes, effect radii, spreads and offsets, auto layout gaps and paddings, grid gaps, min and max sizes, layout grids, font sizes, pixel line heights and letter spacings, paragraph spacing and indent. A value an override or derived row gives is taken as written, already at the instance's scale; the instance's own size is the file's; a missing, non-finite, non-positive or out-of-range factor (outside 0.001 to 1000) changes nothing. Not scaled: vector outline paths (a network's points follow the layer's size), per-point corner radii, fixed grid tracks, image tile scales and glass effects. Tests: `fig::tests::a_scaled_instance_draws_its_layers_at_the_factor` and `fig::scale_factor_tests`. Numbers unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index f42b97e4e..28ec31ca8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (gap 10: library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 7: text reads only the fallback fonts it draws with; a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (gap 13: a boolean group's corner radius (#2222); gap 10: library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 7: text reads only the fallback fonts it draws with; a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -383,14 +383,16 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Estimate:** 5–10 h (video playback in the presenter is most of it; it can reuse the GIF clock). ### 13. Vector tail: none left +- **Landed (2026-11-10):** a boolean group takes a corner radius (and smoothing) that rounds every sharp + corner of its result, convex and concave, with the children's own radii kept and a radius larger + than the edges allow shrinking to fit (#2222; `crates/pathops/src/round_tests.rs`). - **Landed (2026-10-22):** the transform box around points selected in several layers at once, in the page's axes: its corners and edges scale them, outside a corner rotates them and inside drags them, each in its own layer and one undo step for all (`transform_tests`). - **Landed (2026-10-12):** several Bezier handles selected with Shift-click and dragged together, sharp points beside curved segments rounded, a stroke cap and join per point (canvas, PNG, SVG, PDF, outline stroke), the Pen outside edit mode connecting to its own points and continuing a - selected open path. Left: corner radius and smoothing on boolean groups that contain - rectangles, per-point caps and joins on dashed, variable-width and brush + selected open path. Left: per-point caps and joins on dashed, variable-width and brush strokes. - **Landed (2026-10-11):** boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are @@ -593,6 +595,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Gap 13: a boolean group's corner radius rounds every sharp corner of its result (#2222); the boolean clause leaves the 2026-10-12 "Left" list | | 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged | | 2026-10-31 | minor | Gap 10: library analytics list the layers and files using each style and variable; their insertions, detachments and modes stay on the missing list. | | 2026-10-31 | minor | Gap 15: the Controller menu names buttons as PlayStation and Switch controllers do; that item leaves the missing list. Estimate unchanged. Test: `present::tail_tests`. | diff --git a/docs/mcp.md b/docs/mcp.md index 04b37e1f0..a055abe54 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -742,7 +742,7 @@ page line crosses; parts that come apart move to layers of their own above, list `vector.deleteHeal`, `vector.join` / `vector.smoothJoin` (join the selected points while editing), `vector.edit.cornerRadius` (rounds the selected sharp points, next to straight or curved segments: a curve is cut where the arc meets it and keeps the rest of its shape; drawn and exported; -`node.set {cornerRadius}` on a vector rounds every point), `vector.edit.strokeCap {cap, vertices?}` +`node.set {cornerRadius}` on a vector rounds every point, and on a boolean group every sharp corner of its result, while its layers keep their own radius), `vector.edit.strokeCap {cap, vertices?}` and `vector.edit.strokeJoin {join, vertices?}` (the stroke's cap where it ends at these points and its join where it turns there, `null` for the layer's; drawn, exported and outlined), `vector.edit.moveHandles {dx, dy, handles?}` (moves the selected handles by a page offset), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 031521f51..493b34844 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -99,7 +99,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 1 | Canvas & navigation | 6 | 98% | 74% | Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks; outline mode's Include hidden layers and Include object bounds landed) | 1–4 | | 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 | | 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 | -| 4 | Shapes & vector | 8 | 99% | 73% | Corner radius and smoothing on boolean groups that contain rectangles; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, corner smoothing on polygons, stars and closed vectors, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | +| 4 | Shapes & vector | 8 | 99% | 73% | per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, corner smoothing on polygons, stars and closed vectors, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation, a boolean group's corner radius and the miter angle landed) | 1–3 | | 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | | 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 | | 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | @@ -652,6 +652,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | M4, #2222: a boolean group's corner radius rounds every sharp corner of its result (row 4). Numbers unchanged | | 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged | | 2026-11-04 | patch | M4, #1970: justified Chinese, Japanese and Korean text, written without spaces between words, aligns its right edge: the room on each line but a paragraph's last goes between its characters, each a word of its own as for line breaking ("Justify aligns the first word in each line of text to the left-most edge, and the last to the right-most edge"), wherever a line could break there, so none opens before a full stop or closing bracket, nor after an opening one. Latin text widens its spaces as before; mixed text widens both. A gap is reordered with the line's runs as a space of no width would be, so one beside a Hebrew word in Chinese text, or beside a Chinese word within that Hebrew, opens beside the word, not inside it. `figcraft_text::breaks::justify_between`, beside `break_between`, says where. Evidence: `tests::justified_ideographs_spread_the_room_between_characters` (fails on main). | | 2026-11-04 | patch | M8, #1951 (#1506 again): a path broken open by deleting a point (Delete, not Shift+Delete) is filled closed by a straight line across the gap, as an open path's fill is; joining its two ends again (the pen onto the other end, Connect, ⌘J) left the loop without the new segment, so once the points were rounded the fill and the selection outline ran straight across while the stroke followed the segment and its arcs. Joining now closes the loop through the new segment ("Click to close the path."; "You can use the Paint tool to add fills to any closed regions of a vector network."): `ops::connect` puts it into the loop whose segments are the chain between the ends, at the gap and in the loop's own order, so a hole keeps its winding whichever end was clicked first; a loop listed out of walking order is left as it was. Evidence: `vectoredit::tests::a_rounded_chamfer_made_other_ways_fills_along_its_arcs` (fails on main: the loop kept 4 of 5 segments), `a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs` (Shift+Delete, with and without a region fill), `doc::tests::a_chamfered_rectangles_rounded_fill_is_cut_at_every_point` (fill, region path and stroke agree for every loop order and segment direction of a chamfered rectangle). | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 463be3b47..e1be194be 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-04 · **Change:** minor (the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); 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-10 · **Change:** minor (a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); 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: @@ -94,7 +94,7 @@ interaction feel not yet compared. | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); Enter or a double-click ends the path (at the double-click's point, added once) and edits the vector with the Move tool, a double-click with the idle pen edits the layer under it, and in edit mode the pen's Enter or double-click takes the Move tool (#1430, `pen_vector_edit_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) | | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875). Not yet: areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | -| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104); the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with their own icons, two offset squares with the result filled at reduced alpha (#2003) | +| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104); the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with their own icons, two offset squares with the result filled at reduced alpha (#2003); a boolean group takes a corner radius from the Design panel and from `node.set cornerRadius`: every sharp corner of its result rounds, convex and concave, layers inside keep their own radius and do not stack with it, a radius larger than the edges allow shrinks to fit while the field keeps the value typed, Flatten bakes the arcs and a mixed selection reads Mixed; measured in Figma's web app on 2026-11-10 (#2222; `crates/pathops/src/round_tests.rs`, `crates/render/src/boolean_radius_tests.rs`, `crates/engine/src/boolean_radius_tests.rs`, the boolean group test in `crates/ui-egui/src/field_tests.rs`). Pinned and not yet compared with Figma: corner smoothing on a boolean group draws the polygon profile between straight edges, a corner beside a curve stays within the curve piece next to it, and there is no radius handle on the canvas | | Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Text: wrap style (Auto, Balance, Pretty) | done | per text layer (`wrapStyle` on `node.set`, `text.wrapStyle`, the Type settings control under Vertical trim, inspect, CSS `text-wrap: balance` or `pretty`): Auto breaks greedily; Balance narrows each paragraph as far as it goes without another line, so its lines come out close to the same length (what browsers do for `text-wrap: balance`); Pretty keeps lines full and, when the last line is one word, narrows one of the last four lines just enough to give a word to the lines after it (Explore text properties: "Auto: Figma fits as many words as possible on each line before wrapping to the next", "Balance: Figma distributes text as evenly as possible across every line, so each line is close to the same length", "Pretty: Figma keeps each line full, but adjusts the last four lines to avoid an orphan"). Saved line breaks from a file still win. Not yet included: per-paragraph settings, text-style support and the `.fig` import/export mapping (#1827). Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented` | | Text: direction override (Text ▸ Text direction) | done | per paragraph (`ParaList::direction`: auto, ltr, rtl): Text ▸ Text direction ▸ Auto / Left to right / Right to left and `text.direction {direction, ids?}` set the edited paragraphs while editing, else every paragraph of the selected texts; `node.set textDirection` the layer's (or the chars `start`/`end` name); the Type settings control appears once a right-to-left script is in the text or a direction is forced; inspect reports `textDirection` when the paragraphs agree and `lineDirections` otherwise; Developer Mode's CSS writes `direction: ltr` or `rtl` when every paragraph is forced the same way (with `text-align: left` beside `rtl`, as Left is absolute on the canvas); `.fig` import and export carry each line's `sourceDirectionality`, read up to the paragraph count. Layout gives the paragraph its base level, so a forced right-to-left paragraph of Latin letters starts at the right, mirrors its list gutter and indent and orders its runs right to left, and a forced left-to-right paragraph of Hebrew the other way ("By default, Figma automatically handles text direction based on language detection. However, controls are available to override text direction per paragraph", "Go to Text > Text direction. Select Left to right or Right to left", Add right-to-left text). Not included: a direction in text styles (the article names none). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests` | @@ -187,6 +187,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | M4, #2222: a boolean group takes a corner radius that rounds every sharp corner of its result (measured in Figma's web app), Boolean groups row | | 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` | | 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged | | 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. | From bae366ba519ec28c02720184ccfc335c40892d81 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 10 Nov 2026 13:53:13 -0500 Subject: [PATCH 14/17] M4: Boolean groups: test the one-radius panel through corner smoothing (#2222) The field test's "no field per corner" check passed for any shape, since corner fields are built only while the individual-corners popover is open. A boolean group's button now has its own test beside the polygons' (shared helper): it is named Corner smoothing, opens no corner fields, and sets the smoothing in one undo step. ROADMAP's status line reads minor, as the other docs do. Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and wrote; Fable 5.1 reviewed --- ROADMAP.md | 2 +- crates/ui-egui/src/corner_individual_tests.rs | 49 +++++++++++++------ crates/ui-egui/src/field_tests.rs | 1 - 3 files changed, 34 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5d53c2ff1..c1b55fd17 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** patch (a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; 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 Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; 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 Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs index 281ab6f2a..117b90db3 100644 --- a/crates/ui-egui/src/corner_individual_tests.rs +++ b/crates/ui-egui/src/corner_individual_tests.rs @@ -166,25 +166,42 @@ fn polygons_and_stars_take_corner_smoothing() { for cmd in ["shape.polygon", "shape.star"] { let mut s = Session::new(); let id = NodeId(s.execute(cmd, &json!({"x": 0, "y": 0, "width": 120, "height": 120})).unwrap()["id"].as_u64().unwrap()); - s.execute("node.set", &json!({"ids": [id], "props": {"cornerRadius": 8}})).unwrap(); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - let mut u = Ui::new(s); - // With no corners of their own, the button is named for what it opens. - let t = named_button(&mut u, "Corner smoothing"); - click(&mut u, t); - assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "{cmd}: one radius, no corner fields"); - let smoothing = u.ctx.read_response(Id::unique("smoothing").with("field")).expect("the smoothing field shows").rect.center(); - let history = u.app.session.undo_depth().0; - click(&mut u, smoothing); - u.frame(vec![Event::Text("60".into())]); - enter(&mut u); - u.frame(vec![]); - let n = u.app.session.doc().unwrap().node(id).unwrap().clone(); - assert!((n.corners.smoothing - 0.6).abs() < 1e-9, "{cmd}: {}", n.corners.smoothing); - assert_eq!(u.app.session.undo_depth().0, history + 1, "{cmd}: one undo step"); + takes_corner_smoothing(s, id, cmd); } } +/// A boolean group has one radius for its whole result (#2222): like a polygon, its button opens +/// corner smoothing, with no field per corner. +#[test] +fn a_boolean_group_opens_corner_smoothing_and_has_no_corner_fields() { + let mut s = Session::new(); + let rect = |s: &mut Session, x: i32| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap()["id"].clone(); + let (a, b) = (rect(&mut s, 0), rect(&mut s, 60)); + let g = NodeId(s.execute("bool.union", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap()); + takes_corner_smoothing(s, g, "bool.union"); +} + +/// With `id` rounded at 8 and selected: the panel's button is named Corner smoothing and opens +/// the smoothing field with no field per corner, and typing 60 sets the smoothing in one undo step. +fn takes_corner_smoothing(mut s: Session, id: NodeId, what: &str) { + s.execute("node.set", &json!({"ids": [id], "props": {"cornerRadius": 8}})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let mut u = Ui::new(s); + // With no corners of their own, the button is named for what it opens. + let t = named_button(&mut u, "Corner smoothing"); + click(&mut u, t); + assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "{what}: one radius, no corner fields"); + let smoothing = u.ctx.read_response(Id::unique("smoothing").with("field")).expect("the smoothing field shows").rect.center(); + let history = u.app.session.undo_depth().0; + click(&mut u, smoothing); + u.frame(vec![Event::Text("60".into())]); + enter(&mut u); + u.frame(vec![]); + let n = u.app.session.doc().unwrap().node(id).unwrap().clone(); + assert!((n.corners.smoothing - 0.6).abs() < 1e-9, "{what}: {}", n.corners.smoothing); + assert_eq!(u.app.session.undo_depth().0, history + 1, "{what}: one undo step"); +} + /// A closed vector shows the same Corner smoothing control, and its iOS button sets 60% in one /// undo step; an open vector, which has no closed shape to smooth, shows no smoothing control. #[test] diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 4322c8808..b71979f37 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -1416,7 +1416,6 @@ fn a_boolean_group_has_one_corner_radius_field_and_a_mixed_selection_reads_mixed let radii = |u: &Ui, n: NodeId| u.app.session.doc().unwrap().node(n).unwrap().corners.radii; assert_eq!(radii(&u, g), [20.0; 4]); assert_eq!(undo_steps(&u), steps + 1, "one undo step"); - assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "no field per corner"); u.app.run("select.set", json!({"ids": [g, r]})); assert_eq!(field(&mut u, "Corner radius").1, "Mixed"); type_into(&mut u, "Corner radius", "8", Key::Enter); From ec105d0f5609796462e570e7bfff9b5b526acf91 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 10 Nov 2026 14:44:12 -0500 Subject: [PATCH 15/17] M4: The radius field shows what a one-radius layer draws (#2222) A boolean group, polygon, star or vector draws its first radius. Per-corner values a script or a file left behind made its field read Mixed, and a relative edit started from the largest. The field now shows radii[0] for those kinds, and a relative edit starts from it; rectangles and frames are unchanged. Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and wrote --- crates/ui-egui/src/field_tests.rs | 14 ++++++++++++++ crates/ui-egui/src/panels/right/paints.rs | 10 +++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index b71979f37..5af141b7d 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -1421,3 +1421,17 @@ fn a_boolean_group_has_one_corner_radius_field_and_a_mixed_selection_reads_mixed type_into(&mut u, "Corner radius", "8", Key::Enter); assert_eq!((radii(&u, g), radii(&u, r)), ([8.0; 4], [8.0; 4])); } + +/// A boolean group draws its first radius; per-corner values a script or a file left behind don't +/// make its field read Mixed, and a relative edit starts from the radius it draws. +#[test] +fn a_boolean_groups_field_shows_the_radius_it_draws() { + let (mut u, a, b) = setup(); + let g = id(u.app.session.execute("bool.union", &json!({"ids": [a, b]})).unwrap()); + let corners = json!({"topRightRadius": 30, "bottomRightRadius": 30, "bottomLeftRadius": 30}); + u.app.session.execute("node.set", &json!({"ids": [g], "props": corners})).unwrap(); + u.app.run("select.set", json!({"ids": [g]})); + assert_eq!(field(&mut u, "Corner radius").1, "0", "the radius it draws"); + type_into(&mut u, "Corner radius", "+10", Key::Enter); + assert_eq!(u.app.session.doc().unwrap().node(g).unwrap().corners.radii, [10.0; 4], "0 + 10, not 30 + 10"); +} diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index 157c65731..76f9c38c8 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -57,7 +57,10 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N // For polygons, stars, boolean groups and closed vectors the same panel holds only the Corner // smoothing row. let smooth_ok = radius_ok && crate::selection_cache::every(doc, sel, |n| n.kind.has_corner_smoothing()); - let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0)); + // A layer with one radius (polygon, star, boolean group, vector) draws its first one, whatever + // the other three hold; a rectangle or frame shows a value only when its four corners agree. + let shown = |n: &Node| if n.kind.has_independent_corners() { n.corners.uniform() } else { n.corners.radii.first().copied() }; + let r = common(doc, sel, |n| shown(n).map(|r| (r * 100.0).round() / 100.0)); widgets::sublabels(ui, tr("Opacity"), radius_ok.then_some(tr("Corner radius"))); row(ui, 32.0, |ui| { let lead = Lead::Icon(Icon::Opacity, tr("Opacity")); @@ -75,8 +78,9 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N "", col, ); - // A layer with corners of its own starts from its largest. - let radius_of = |n: &Node| n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max)); + // A relative edit starts from the radius shown; a rectangle with differing corners + // starts from its largest. + let radius_of = |n: &Node| shown(n).unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max)); app.field_of(&ids, "cornerRadius", e, radius_of, |v| json!(v.max(0.0))); let open = ui.data(|d| d.get_temp::(Id::unique("indep_corners"))).unwrap_or(false); // Polygons, stars and boolean groups have no corners of their own: the button opens only From 4c91f9084b5d50e9323b77693d33764d661e20cf Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 10 Nov 2026 15:39:09 -0500 Subject: [PATCH 16/17] M4: round_corners: each contour's own size sets its weld tolerance (#2222) The tolerance that drops the sweep's slivers came from the whole path's bounding box, so a contour a million units wide welded away the real half-unit edges of a separate small contour. Each closed subpath now sets its own. Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and wrote --- crates/pathops/src/round.rs | 6 ++++-- crates/pathops/src/round_tests.rs | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/pathops/src/round.rs b/crates/pathops/src/round.rs index 40ab03759..385f1faba 100644 --- a/crates/pathops/src/round.rs +++ b/crates/pathops/src/round.rs @@ -114,10 +114,12 @@ pub fn round_corners(path: &BezPath, radius: f64, smoothing: f64) -> BezPath { return path.clone(); } let smoothing = if smoothing.is_finite() { smoothing.clamp(0.0, 1.0) } else { 0.0 }; - let bb = path.bounding_box(); - let weld = (bb.width().max(bb.height()) * 1e-6).max(1e-9); let mut out = BezPath::new(); for sub in figcraft_geom::split_subpaths(path) { + // Each contour's own size sets its cleanup tolerance, so a large contour can't weld away + // the real short edges of a small island or hole beside it. + let bb = sub.bounding_box(); + let weld = (bb.width().max(bb.height()) * 1e-6).max(1e-9); let closed = matches!(sub.elements().last(), Some(PathEl::ClosePath)); match closed.then(|| round_subpath(&sub, radius, smoothing, weld)).flatten() { Some(p) => out.extend(p.elements().iter().copied()), diff --git a/crates/pathops/src/round_tests.rs b/crates/pathops/src/round_tests.rs index eda74b464..0c22b5363 100644 --- a/crates/pathops/src/round_tests.rs +++ b/crates/pathops/src/round_tests.rs @@ -236,3 +236,21 @@ fn junk_never_panics() { zig.close_path(); assert!(all_finite(&round_corners(&zig, 2.0, 0.0))); } + +/// The cleanup tolerance is each contour's own: a contour a million units wide doesn't weld away +/// the half-unit notch of a separate small contour in the same path. +#[test] +fn a_large_contour_does_not_weld_a_small_ones_short_edges() { + let mut p = rect(0.0, 0.0, 1e6, 1e6); + let x = 2e6; + for (i, (dx, y)) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (5.5, 10.0), (5.5, 9.5), (5.0, 9.5), (5.0, 10.0), (0.0, 10.0)].into_iter().enumerate() + { + if i == 0 { p.move_to((x + dx, y)) } else { p.line_to((x + dx, y)) } + } + p.close_path(); + let out = round_corners(&p, 0.001, 0.0); + let reaches = |q: Point| { + out.elements().iter().any(|el| matches!(el, PathEl::MoveTo(e) | PathEl::LineTo(e) | PathEl::CurveTo(_, _, e) if (*e - q).hypot() < 0.01)) + }; + assert!(reaches(pt(x + 5.0, 9.5)) && reaches(pt(x + 5.5, 9.5)), "the notch's floor is kept"); +} From a69b27fd1e66af3f3bfffbc73f82bc4758f76428 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 12 Nov 2026 09:16:53 +0700 Subject: [PATCH 17/17] Spanish labels for Invert zoom direction --- crates/ui-egui/locales/es.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 258a1527d..1dfdc7076 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -35,6 +35,7 @@ status = "draft — needs native review" "Couldn't download the variables" = "No se pudieron descargar las variables" "Couldn't download {name}" = "No se pudo descargar {name}" "Create from color" = "Crear a partir del color" +"Invert zoom direction" = "Invertir la dirección del zoom" "New collection" = "Nueva colección" "Delete results" = "Eliminar resultados" "Pasted design layers" = "Capas de diseño pegadas" @@ -2214,6 +2215,7 @@ status = "draft — needs native review" "plugins.runLast" = "Ejecutar el último plugin" "pref.autoSave" = "Guardar archivos automáticamente" "pref.highlightHover" = "Resaltar capas al pasar el cursor" +"pref.invertZoom" = "Invertir la dirección del zoom" "pref.keepTool" = "Mantener la herramienta tras usarla" "pref.language" = "Idioma de la interfaz" "pref.nudge" = "Cantidad de desplazamiento"