From 89d8b50e9df18e553969b4571fd76cad0ea2ddce Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 12 Nov 2026 09:56:45 -0500 Subject: [PATCH 1/2] M8: polygons and stars get a Count handle on the canvas, as in Figma A selected polygon or star shows a Count handle on its first point clockwise from the top (a polygon's vertex 1, a star's outer point 1, not inset). Dragging it sets the count to round(360 / the pointer's clockwise angle from the top point about the centre), measured in the shape's own frame and clamped to 3 to 60, written through node.set pointCount in one undo step. The readout and hover label read "Count N". Measured in Figma's web app on 2026-11-12; a polygon's maximum wasn't read, so it is clamped at 60 like the star's. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Fable 5.2 queued; Opus 5.5 briefed; Sonnet 5.5 measured and wrote at medium --- crates/engine/src/canvas/shape_handles.rs | 45 ++++++++- crates/engine/src/shape_handles_tests.rs | 106 +++++++++++++++++++++- crates/ui-egui/src/canvas/overlays.rs | 1 + docs/ui-parity.md | 5 +- 4 files changed, 150 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/canvas/shape_handles.rs b/crates/engine/src/canvas/shape_handles.rs index 6ddd99943..726f7d011 100644 --- a/crates/engine/src/canvas/shape_handles.rs +++ b/crates/engine/src/canvas/shape_handles.rs @@ -6,12 +6,16 @@ //! [`INSET_PX`] in, so it can be grabbed at radius 0). Dragging it along the bisector sets the //! radius of every corner; with Alt, only that corner of a rectangle or frame (the documentation //! is silent on Alt). +//! - **Count**: a polygon's or star's sits on its first point clockwise from the top; dragging it +//! sets the count from the pointer's angle about the centre, measured clockwise from the top +//! point: `round(360 / angle)`, clamped to 3 to 60 (measured in Figma's web app, 2026-11-12; a +//! polygon's maximum wasn't read). //! - **Ratio**: a star's sits on its first inner point; dragging it towards or away from the //! centre sets the inner radius as a share of the outer one. //! //! Positions are in page coordinates and work for rotated and flipped layers at any zoom; the //! handles hide on layers smaller than [`MIN_PX`] on screen. The Move tool's gesture (`tools.rs`) -//! writes the result through `node.set` (`cornerRadius`, `topLeftRadius`…, `innerRadius`), one +//! writes the result through `node.set` (`cornerRadius`, `topLeftRadius`…, `innerRadius`, `pointCount`), one //! undo step per drag. use figcraft_doc::{Document, Node, NodeId, NodeKind}; @@ -20,18 +24,22 @@ use serde::Serialize; use serde_json::{Map, Value, json}; /// One shape handle: a corner's radius (corner index: tl, tr, br, bl; 0 for a polygon's or -/// star's top point) or a star's ratio. +/// star's top point), a star's ratio or a polygon's or star's point count. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum ShapeHandle { Radius { corner: usize }, Ratio, + Count, } /// Layers smaller than this on screen (either side, in pixels) show no shape handles. pub const MIN_PX: f64 = 48.0; /// How far in from its corner a handle sits at least (screen pixels). pub const INSET_PX: f64 = 10.0; +/// The fewest and most points the Count handle sets (a star's range in the help center). +const COUNT_MIN: f64 = 3.0; +const COUNT_MAX: f64 = 60.0; /// How near the pointer must be to pick a handle (screen pixels). const HIT_PX: f64 = 6.0; @@ -113,6 +121,29 @@ fn ratio_point(n: &Node, count: u32, ratio: f64) -> Option { figcraft_geom::star_points(n.width, n.height, count, ratio).get(1).copied() } +/// The Count handle (local): a polygon's vertex 1 or a star's outer point 1, the first clockwise +/// from the top. +fn count_point(n: &Node) -> Option { + match n.kind { + NodeKind::Polygon { count } => figcraft_geom::polygon_points(n.width, n.height, count).get(1).copied(), + NodeKind::Star { count, inner_ratio } => figcraft_geom::star_points(n.width, n.height, count, inner_ratio).get(2).copied(), + _ => None, + } +} + +/// The count a pointer at local point `q` sets: `round(360 / angle)` for its clockwise angle from +/// the top about the centre, on the unit circle the shape's box maps from; finite for any `q`. +fn count_at(n: &Node, q: Point) -> Option { + let (rx, ry) = (n.width / 2.0, n.height / 2.0); + if rx.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) || ry.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { + return None; + } + let (x, y) = (q.x / rx - 1.0, q.y / ry - 1.0); + let angle = x.atan2(-y).rem_euclid(std::f64::consts::TAU).to_degrees(); + let count = (360.0 / angle.max(1e-9)).round(); + count.is_finite().then(|| count.clamp(COUNT_MIN, COUNT_MAX)) +} + /// The shape handles of the selection at `zoom` (screen pixels per page unit): each handle and /// where it is (page coordinates). pub fn handles(doc: &Document, sel: &[NodeId], zoom: f64) -> Vec<(ShapeHandle, [f64; 2])> { @@ -128,6 +159,9 @@ pub fn handles(doc: &Document, sel: &[NodeId], zoom: f64) -> Vec<(ShapeHandle, [ { out.push((ShapeHandle::Ratio, pt(p))); } + if let Some(p) = count_point(n) { + out.push((ShapeHandle::Count, pt(p))); + } out } @@ -160,6 +194,10 @@ pub fn drag(doc: &Document, id: NodeId, h: ShapeHandle, p: Point, alt: bool) -> }; Some(Map::from_iter([(key.to_string(), json!(r))])) } + ShapeHandle::Count => { + count_point(n)?; + Some(Map::from_iter([("pointCount".to_string(), json!(count_at(n, q)?))])) + } ShapeHandle::Ratio => { let NodeKind::Star { count, .. } = n.kind else { return None }; // Along the inner point's ray, measured on the unit circle the star's box maps from. @@ -172,11 +210,12 @@ pub fn drag(doc: &Document, id: NodeId, h: ShapeHandle, p: Point, alt: bool) -> } } -/// The live readout while dragging `h` of `id`: the corner's radius, or the star's ratio in percent. +/// The live readout while dragging `h` of `id`: the corner's radius, the star's ratio in percent or the point count. pub fn value(doc: &Document, id: NodeId, h: ShapeHandle) -> Option { let n = doc.node(id)?; let v = match (h, &n.kind) { (ShapeHandle::Ratio, NodeKind::Star { inner_ratio, .. }) => inner_ratio * 100.0, + (ShapeHandle::Count, NodeKind::Polygon { count }) | (ShapeHandle::Count, NodeKind::Star { count, .. }) => f64::from(*count), (ShapeHandle::Radius { corner }, _) => corners(n).into_iter().nth(corner).map(|(c, r)| r.clamp(0.0, c.max_r))?, _ => return None, }; diff --git a/crates/engine/src/shape_handles_tests.rs b/crates/engine/src/shape_handles_tests.rs index 73647f4d6..65220d7a7 100644 --- a/crates/engine/src/shape_handles_tests.rs +++ b/crates/engine/src/shape_handles_tests.rs @@ -3,6 +3,7 @@ use figcraft_doc::{NodeId, NodeKind}; use figcraft_geom::Point; use serde_json::{Value, json}; +use std::f64::consts::TAU; use crate::Session; use crate::params::id_of; @@ -63,7 +64,11 @@ fn polygon_and_star_handles() { let doc = s.doc().unwrap().clone(); let hs = handles(&doc, &[star], 1.0); let kinds: Vec = hs.iter().map(|h| h.0).collect(); - assert_eq!(kinds, vec![ShapeHandle::Radius { corner: 0 }, ShapeHandle::Ratio], "a star has a radius and a ratio handle"); + assert_eq!( + kinds, + vec![ShapeHandle::Radius { corner: 0 }, ShapeHandle::Ratio, ShapeHandle::Count], + "a star has a radius, a ratio and a count handle" + ); // The ratio handle sits on the first inner point; dragging it to the centre's side shrinks it. let ratio_at = hs[1].1; let inner = figcraft_geom::star_points(200.0, 200.0, 5, 0.38)[1]; @@ -78,7 +83,7 @@ fn polygon_and_star_handles() { assert!(radii(&s, star)[0] > 1.0, "the star's points round"); let mut s = Session::new(); let poly = create(&mut s, "shape.polygon", json!({"x": 0, "y": 0, "width": 200, "height": 200})); - assert_eq!(handles(s.doc().unwrap(), &[poly], 1.0).len(), 1, "a polygon has one radius handle"); + assert_eq!(handles(s.doc().unwrap(), &[poly], 1.0).len(), 2, "a polygon has a radius and a count handle"); } #[test] @@ -100,3 +105,100 @@ fn handles_show_only_where_they_can_be_used() { s.execute("vector.edit.begin", &json!({"id": r})).unwrap(); assert!(s.shape_handles().is_empty(), "none while editing points"); } + +fn count_of(s: &Session, id: NodeId) -> u32 { + match s.doc().unwrap().node(id).unwrap().kind { + NodeKind::Polygon { count } | NodeKind::Star { count, .. } => count, + _ => panic!("a polygon or star"), + } +} + +fn count_handle(s: &Session, id: NodeId) -> [f64; 2] { + handles(s.doc().unwrap(), &[id], 1.0).into_iter().find(|h| h.0 == ShapeHandle::Count).expect("a count handle").1 +} + +/// The page point at `deg` degrees clockwise from the shape's own top, `r` from its centre (200 x 200 box). +fn at_angle(s: &Session, id: NodeId, deg: f64, r: f64) -> (f64, f64) { + let a = deg.to_radians(); + let p = s.doc().unwrap().world_transform(id) * Point::new(100.0 + r * a.sin(), 100.0 - r * a.cos()); + (p.x, p.y) +} + +fn count_drag(s: &mut Session, id: NodeId, deg: f64) { + let h = count_handle(s, id); + let to = at_angle(s, id, deg, 80.0); + drag(s, (h[0], h[1]), to, false); +} + +#[test] +fn a_polygon_has_a_count_handle_on_its_first_vertex_clockwise_from_the_top() { + let mut s = Session::new(); + let poly = create(&mut s, "shape.polygon", json!({"x": 0, "y": 0, "width": 200, "height": 200})); + s.execute("node.set", &json!({"ids": [poly], "props": {"pointCount": 5}})).unwrap(); + let h = count_handle(&s, poly); + let v1 = figcraft_geom::polygon_points(200.0, 200.0, 5)[1]; + assert!((Point::new(h[0], h[1]) - v1).hypot() < 1e-6, "on vertex 1, not inset: {h:?} vs {v1:?}"); + assert!((h[0] - (100.0 + 100.0 * (TAU / 5.0).sin())).abs() < 1e-6, "72 degrees from the top"); + // Hovering it finds it, not the radius handle. + assert_eq!(hit(s.doc().unwrap(), &[poly], Point::new(h[0], h[1]), 1.0).map(|x| x.1), Some(ShapeHandle::Count)); +} + +#[test] +fn dragging_the_count_handle_sets_the_count_by_the_angle_about_the_centre() { + let mut s = Session::new(); + let poly = create(&mut s, "shape.polygon", json!({"x": 0, "y": 0, "width": 200, "height": 200})); + s.execute("node.set", &json!({"ids": [poly], "props": {"pointCount": 5}})).unwrap(); + for (deg, want) in [(90.0, 4), (45.0, 8), (120.0, 3), (0.5, 60), (0.0, 60), (200.0, 3)] { + count_drag(&mut s, poly, deg); + assert_eq!(count_of(&s, poly), want, "{deg} degrees"); + } + // The radius from the centre doesn't matter. + let h = count_handle(&s, poly); + let to = at_angle(&s, poly, 90.0, 30.0); + drag(&mut s, (h[0], h[1]), to, false); + assert_eq!(count_of(&s, poly), 4, "90 degrees near the centre"); + // The readout is the count. + let doc = s.doc().unwrap(); + assert_eq!(crate::shape_handles::value(doc, poly, ShapeHandle::Count), Some(4.0)); + // The finite guard: a pointer on the centre gives a finite count. + let h = count_handle(&s, poly); + drag(&mut s, (h[0], h[1]), (100.0, 100.0), false); + assert!((3..=60).contains(&count_of(&s, poly))); +} + +#[test] +fn dragging_a_stars_count_handle_sets_its_points() { + let mut s = Session::new(); + let star = create(&mut s, "shape.star", json!({"x": 0, "y": 0, "width": 200, "height": 200})); + let h = count_handle(&s, star); + let outer = figcraft_geom::star_points(200.0, 200.0, 5, 0.38)[2]; + assert!((Point::new(h[0], h[1]) - outer).hypot() < 1e-6, "on its first outer point clockwise from the top"); + for (deg, want) in [(90.0, 4), (45.0, 8), (130.0, 3), (1.0, 60)] { + count_drag(&mut s, star, deg); + assert_eq!(count_of(&s, star), want, "{deg} degrees"); + } + let NodeKind::Star { inner_ratio, .. } = s.doc().unwrap().node(star).unwrap().kind else { panic!("star") }; + assert!((inner_ratio - 0.38).abs() < 0.02, "the ratio stays through count drags: {inner_ratio}"); +} + +#[test] +fn a_count_drag_is_one_undo_step() { + let mut s = Session::new(); + let poly = create(&mut s, "shape.polygon", json!({"x": 0, "y": 0, "width": 200, "height": 200})); + s.execute("node.set", &json!({"ids": [poly], "props": {"pointCount": 5}})).unwrap(); + count_drag(&mut s, poly, 45.0); + assert_eq!(count_of(&s, poly), 8); + s.undo().unwrap(); + assert_eq!(count_of(&s, poly), 5, "one Ctrl+Z takes the drag back"); +} + +#[test] +fn a_rotated_polygons_count_angle_is_measured_in_its_own_frame() { + let mut s = Session::new(); + let poly = create(&mut s, "shape.polygon", json!({"x": 0, "y": 0, "width": 200, "height": 200})); + s.execute("node.set", &json!({"ids": [poly], "props": {"pointCount": 5, "rotation": 30}})).unwrap(); + for (deg, want) in [(90.0, 4), (45.0, 8)] { + count_drag(&mut s, poly, deg); + assert_eq!(count_of(&s, poly), want, "{deg} degrees in the shape's frame"); + } +} diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 3e15693f9..b285d8792 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -701,6 +701,7 @@ fn shape_overlay(app: &FigApp, painter: &egui::Painter, col: Color32) { let text = match hint.handle { ShapeHandle::Radius { .. } => format!("{} {}", tr("Radius"), hint.value), ShapeHandle::Ratio => format!("{} {}%", tr("Ratio"), hint.value), + ShapeHandle::Count => format!("{} {}", tr("Count"), hint.value), }; pill(painter, c + vec2(12.0, 12.0), Align2::LEFT_TOP, text, col); } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1911dbbcd..2bbf44c03 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-10 · **Change:** minor (the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; 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); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); 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; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); 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-12 · **Change:** minor (M8: a selected polygon or star has a Count handle on the canvas, on its first point clockwise from the top; dragging it sets the count from the pointer's angle about the centre, as measured in Figma's web app; the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; 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); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); 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; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); 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: @@ -95,7 +95,7 @@ interaction feel not yet compared. | Move, hand (Space), zoom (hold Z, Alt out, drag area) | done | the middle mouse button and a mouse's side buttons (4 and 5) pan as Space does (#1758) | | Frame (presets panel, swap orientation), section, slice | done | | | 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 | +| 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; a Count handle sits on a polygon's first vertex or a star's first outer point clockwise from the top, and the count follows the pointer's angle about the centre, `round(360 / angle)` clockwise from the top point, clamped to 3 to 60, in one undo step (measured in Figma's web app, 2026-11-12; a polygon's maximum wasn't read and is clamped at 60 like the star's, the help center's range); 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, 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 | | 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), and bakes a vector's point corner radius into its path (each rounded point becomes the arc's end points joined by curves, the layer stays a vector and its box fits the real geometry, #2195); 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 | @@ -327,4 +327,5 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged | 2026-10-11 | minor | Outline mode done: outlines for the selection (the selected layers draw as outlines, the rest normally) and mask outlines | | 2026-10-11 | minor | Design panel arc fields, live boolean editing (operands on the canvas, live results, per-operand operations), shape builder on strokes and open paths, one-sided widths (Alt-drag a width handle) | | 2026-11-09 | patch | M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`) | +| 2026-11-12 | minor | M8: a selected polygon or star has a Count handle on the canvas (hover label Count N), measured in Figma's web app on 2026-11-12: it sits on the first vertex clockwise from the top (a star's first outer point, not inset), and a drag sets the count to round(360 / the pointer's clockwise angle from the top point about the centre), so 90 degrees gives 4, 72 gives 5 and 45 gives 8, with the step shrinking as the count grows; one drag is one undo step. The count is clamped to 3 to 60 (the help center's range for a star; a polygon's maximum was not read). Tests: `a_polygon_has_a_count_handle_on_its_first_vertex_clockwise_from_the_top`, `dragging_the_count_handle_sets_the_count_by_the_angle_about_the_centre`, `a_count_drag_is_one_undo_step` | | 2026-10-10 | major | Created from the ROADMAP's look-and-feel and interaction items, the keymap and the canvas code | From d773ab2985f1d98ff797494a9fcf7412b1210bc4 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 12 Nov 2026 11:42:22 -0500 Subject: [PATCH 2/2] M8: a pointer on the shape's centre gives the most points atan2 of two zeros reads as 180 degrees, so the centre gave 3; it now gives 60, and the test asserts that exactly. Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and fixed --- crates/engine/src/canvas/shape_handles.rs | 5 +++++ crates/engine/src/shape_handles_tests.rs | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/canvas/shape_handles.rs b/crates/engine/src/canvas/shape_handles.rs index 726f7d011..b286010ba 100644 --- a/crates/engine/src/canvas/shape_handles.rs +++ b/crates/engine/src/canvas/shape_handles.rs @@ -139,6 +139,11 @@ fn count_at(n: &Node, q: Point) -> Option { return None; } let (x, y) = (q.x / rx - 1.0, q.y / ry - 1.0); + // On the centre there is no direction (atan2 of two zeros would read as 180 degrees): treat it + // as no angle, the most points. + if x.hypot(y) < 1e-9 { + return Some(COUNT_MAX); + } let angle = x.atan2(-y).rem_euclid(std::f64::consts::TAU).to_degrees(); let count = (360.0 / angle.max(1e-9)).round(); count.is_finite().then(|| count.clamp(COUNT_MIN, COUNT_MAX)) diff --git a/crates/engine/src/shape_handles_tests.rs b/crates/engine/src/shape_handles_tests.rs index 65220d7a7..0e3a2f211 100644 --- a/crates/engine/src/shape_handles_tests.rs +++ b/crates/engine/src/shape_handles_tests.rs @@ -160,10 +160,10 @@ fn dragging_the_count_handle_sets_the_count_by_the_angle_about_the_centre() { // The readout is the count. let doc = s.doc().unwrap(); assert_eq!(crate::shape_handles::value(doc, poly, ShapeHandle::Count), Some(4.0)); - // The finite guard: a pointer on the centre gives a finite count. + // A pointer on the centre has no direction: it gives the most points. let h = count_handle(&s, poly); drag(&mut s, (h[0], h[1]), (100.0, 100.0), false); - assert!((3..=60).contains(&count_of(&s, poly))); + assert_eq!(count_of(&s, poly), 60, "a pointer on the centre"); } #[test]