From 6ac20c7d53057f9b16b320fadb50cb020ad7d6a8 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 07:53:14 -0500 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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)); +}