mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 22:02:38 +00:00
Merge pull request #2308 from TrevorBrown-dev/feat/polygon-count-handle
M8: polygons and stars get a Count handle on the canvas, as in Figma
This commit is contained in:
4 files changed
+155
-7
No files matched your search
@@ -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,34 @@ fn ratio_point(n: &Node, count: u32, ratio: f64) -> Option<Point> {
|
||||
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<Point> {
|
||||
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<f64> {
|
||||
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);
|
||||
// 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))
|
||||
}
|
||||
|
||||
/// 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 +164,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 +199,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 +215,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<f64> {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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<ShapeHandle> = 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));
|
||||
// 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_eq!(count_of(&s, poly), 60, "a pointer on the centre");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Zoom to selection leaves a 40 pt margin and clears the bottom toolbar, measured in Figma's web app; M1: Paste over selection puts the pasted layers just above the selected layer; drag modifiers compared in Figma's web app (2026-11-08): Shift locks a move to the dominant axis, Alt copies, Shift keeps a resize's ratio, Alt keeps its centre, all read afresh on every move, and FigCraft matches (Ctrl/Cmd not measurable); a mirror mode (angle, or angle and length) on a sharp point turns it smooth, as in Figma (#2110); M8: Tab while renaming the last layer wraps to the first row, measured in Figma's web app on 2026-11-12; 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 placLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M8: a selected polygon or star has a Count handle on the canvas, on its first point clockwise from the top; Zoom to selection leaves a 40 pt margin and clears the bottom toolbar, measured in Figma's web app; M1: Paste over selection puts the pasted layers just above the selected layer; drag modifiers compared in Figma's web app (2026-11-08): Shift locks a move to the dominant axis, Alt copies, Shift keeps a resize's ratio, Alt keeps its centre, all read afresh on every move, and FigCraft matches (Ctrl/Cmd not measurable); a mirror mode (angle, or angle and length) on a sharp point turns it smooth, as in Figma (#2110); M8: Tab while renaming the last layer wraps to the first row, measured in Figma's web app on 2026-11-12; 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 nLine truncated
|
||||
|
||||
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 only partly compared: the interactions measured so far, case by
|
||||
| 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 mirror mode on a sharp point turns it smooth by the measured rule (#2110); 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); 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); Copy and Cut with points or segments selected take just those (a lone point: the segments touching it) as a vector with the layer's look, at their own place, so a paste outside edit mode is a new layer in place; Cut removes them as Delete does, the path splitting in the same layer with edit mode staying on; Paste while editing adds the clipboard's segments, or a copied shape's or boolean's outline, to the edited network in place with the new points selected, skipping layers with no outline (text, frames, groups; named in `skipped`) and refusing when none has one, as measured in Figma (`vector_clipboard_tests`, #1875). 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 | |
|
||||
@@ -331,4 +331,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 |
|
||||
Reference in new issue
Block a user