mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 05:42:38 +00:00
Merge main into #2191
This commit is contained in:
commit
f286b6bbe6
8 files changed
+390
-25
No files matched your search
@@ -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,7 +708,61 @@ fn handle_list(p: &Value, key: &str) -> Option<Vec<(usize, bool)>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Selection bounds' top-left in the parent's coordinates (what the X/Y fields show).
|
||||
/// 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<f64> {
|
||||
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<AxisEdit> {
|
||||
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<Point> {
|
||||
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 (the `x` and `y` of `vector.edit.bounds`).
|
||||
pub fn selection_position(doc: &Document, ve: &VectorEdit) -> Option<Point> {
|
||||
let net = network(doc, ve.id)?;
|
||||
let t = doc.node(ve.id)?.transform;
|
||||
@@ -935,18 +991,50 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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"));
|
||||
}
|
||||
// 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| {
|
||||
if uniform {
|
||||
let all: Vec<usize> = 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}))
|
||||
}
|
||||
),
|
||||
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")?;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! `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 undo_steps(s: &Session) -> usize {
|
||||
s.ds().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
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::<Vec<_>>();
|
||||
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 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<usize> = (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();
|
||||
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}}),
|
||||
json!({"x": "a"}),
|
||||
json!({"x": {"add": 1, "mul": 2}}),
|
||||
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));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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())]);
|
||||
@@ -261,7 +261,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -294,6 +294,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;
|
||||
|
||||
@@ -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<FieldEdit>, params: impl Fn(f64) -> Value) {
|
||||
@@ -31,6 +31,23 @@ fn point_field(app: &mut FigApp, cmd: &str, edit: Option<FieldEdit>, 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<Edit>) {
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! 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, Modifiers};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::field_tests::{field, scrub, type_into, undo_steps};
|
||||
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));
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (grid Fill (`fr`) tracks share the space of a fixed-size axis by their fractions and let a fixed-size child overflow, as measured in Figma (300 x 200, two rows, 100 x 150 and 100 x 20 children: rows 100 / 100, second child at y 100; with a row gap of 10, rows 95 / 95 and y 105; on Hug the rows fit their content, 150 / 20) -- this replaces the 2026-10-11 rule from the documentation; an exported layer with a background blur or glass keeps the blurred content behind it within its bounds (#2213); a typed number and a full stop at the start of a paragraph starts a numbered list at that number, as measured in Figma 2026-11-11; 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 sessiLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **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; grid Fill (`fr`) tracks share the space of a fixed-size axis by their fractions and let a fixed-size child overflow, as measured in Figma (300 x 200, two rows, 100 x 150 and 100 x 20 children: rows 100 / 100, second child at y 100; with a row gap of 10, rows 95 / 95 and y 105; on Hug the rows fit their content, 150 / 20) -- this replaces the 2026-10-11 rule from the documentation; an exported layer with a background blur or glass keeps the blurred content behind it within its bounds (#2213); a typed number and a full stop at the start of a paragraph starts a numbered list at that number, as measured in Figma 2026-11-11; 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 wiLine truncated
|
||||
|
||||
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
|
||||
@@ -649,6 +649,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 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-11 | patch | M4: typing a number, a full stop and a space at the start of a plain paragraph starts a numbered list at that number (`5. ` then "x", Enter, "y" reads 5. then 6.), as measured in Figma web on 2026-11-11 (it has no start-number field: the start comes only from the typed number); before, only `1. ` and `1)` started a list. `0. `, `12a. ` and a bracket after a number other than 1 stay text (not measured), and the number is capped at 9999. Also measured: a plain paragraph or a bullet item between numbered items restarts the count at 1, and an indented sub-item does not break the outer count; those rows were pinned as silent. Tests: `text_tail_tests::typed_list_starts`, `typed_number_starts_the_list_there`, `tail_tests` restart rows. Numbers unchanged |
|
||||
| 2026-11-11 | patch | M2.1: grid Fill (`fr`) tracks share the space of a fixed-size axis by their fractions and let a fixed-size child overflow, as measured in Figma (300 x 200, two rows, 100 x 150 and 100 x 20 children: rows 100 / 100, second child at y 100; with a row gap of 10, rows 95 / 95 and y 105; on Hug the rows fit their content, 150 / 20) (this replaces the 2026-10-11 rule from the documentation). Percentages 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 |
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesisLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-widtLine truncated
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
@@ -96,6 +96,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, in the layer being edited (measured in Figma on 2026-11-08). Not yet: areas of other layers edited at once; the Point section's X and Y over points of several edited layers |
|
||||
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377), and bakes a vector's point corner radius into its path (each rounded point becomes the arc's end points joined by curves, the layer stays a vector and its box fits the real geometry, #2195); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875). Not yet: areas of other layers edited at once |
|
||||
| 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); Copy and Cut with points or segments selected take just those (a lone point: the segments touching it) as a vector with the layer's look, at their own place, so a paste outside edit mode is a new layer in place; Cut removes them as Delete does, the path splitting in the same layer with edit mode staying on; Paste while editing adds the clipboard's segments, or a copied shape's or boolean's outline, to the edited network in place with the new points selected, skipping layers with no outline (text, frames, groups; named in `skipped`) and refusing when none has one, as measured in Figma (`vector_clipboard_tests`, #1875). Not yet: areas of other layers edited at once |
|
||||
| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
|
||||
@@ -213,6 +214,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-09 | patch | M3, #2188: Selection colors shows for a selection holding a layer with children (a frame, group, component or instance) from one paint group, and for layers without children only from two paint groups (groups are counted by style, variable and paint value, as `collect` does, not by distinct colours); measured in Figma's web app on 2026-11-09 for the examples below only, and instance and component behaviour was not measured: a frame holding one red rectangle, a group of two red rectangles and a frame with a lone black-stroked rectangle show one row; one red rectangle, two red rectangles and a childless green frame show none; a red and a blue loose rectangle show two (two paint groups). A childless rectangle with a red fill and a blue stroke has two paint groups, so the engine shows the section (not measured). Tests: `selection_colors_tests`. Counts 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-09 | patch | Preferences ▸ Invert zoom direction (#2145): Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom the other way; pinch and panning unchanged. Numbers unchanged. |
|
||||
| 2026-11-07 | patch | Transform: Shift while rotating lands on absolute multiples of 15°, measured in Figma (a layer at 7° or -4.27° Shift-rotated by its corner ends at 15°); FigCraft rounded the turn since the drag began, so 7° landed on 7 + k*15. The gesture keeps the selection's shared rotation and rounds base + turn; a selection with different rotations keeps rounding the turn. Test: `shift_rotating_lands_on_multiples_of_15_not_steps_from_the_start` |
|
||||
| 2026-11-10 | patch | M1 review follow-up: strengthen canvas alpha, clipping/coverage, grid geometry/order and drawn-theme tests; use settled fresh sidebars at each width, check HEX visibility and opacity alignment, and assert picker toggling preserves page alpha. Production behavior unchanged. |
|
||||
|
||||
Reference in new issue
Block a user