Merge #1189: M4: vector edit transform box around points of several layers (@marcoarnulfo)

# Conflicts:
#	docs/target-app-parity.md
#	docs/ui-parity.md
This commit is contained in:
bflatastic
2026-10-24 09:15:11 +07:00
8 changed files with 199 additions and 29 deletions
@@ -169,13 +169,19 @@ fn editing_several_vectors_at_once() {
assert!(near(page_pos(&s, a, atl), (1.0, 0.0)) && near(page_pos(&s, b, btl), (201.0, 0.0)), "an arrow nudges the points in every layer");
s.undo().unwrap();
assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "one undo step for every layer");
// Dragging one selected point drags them all.
let active = editing(&s).unwrap().id;
let grab = if active == a { (0.0, 0.0) } else { (200.0, 0.0) };
drag(&mut s, grab, (grab.0 + 10.0, grab.1 + 20.0), NONE);
assert!(near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)), "dragging one selected point moves them all");
// Dragging one selected point drags them all (one on the transform box's edge: its corners
// scale, as with points of one layer).
let atr = point_at(&s, a, (100.0, 0.0));
ex(&mut s, "vector.edit.select", json!({"id": a, "vertices": [atr], "add": true}));
drag(&mut s, (100.0, 0.0), (110.0, 20.0), NONE);
assert!(
near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, a, atr), (110.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)),
"dragging one selected point moves them all"
);
s.undo().unwrap();
assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "the drag is one undo step");
ex(&mut s, "vector.edit.select", json!({"vertices": [atl]}));
ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true}));
// Corner radius, mirroring and deleting reach every layer's selected points.
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 10}));
assert!(net(&s, a).vertices[atl].radius == 10.0 && net(&s, b).vertices[btl].radius == 10.0, "the corner radius applies to every layer");
+1 -1
View File
@@ -4,7 +4,7 @@
//! Entering edit mode on a non-vector shape converts it to a vector first (like the incumbent).
//! Vertex coordinates are node-local; after each edit the node is re-fitted so its box wraps the
//! network again. The network edits themselves live in [`ops`]; pointer gestures in `pointer.rs`,
//! the transform box around several points in [`transform`], point snapping in `point_snap.rs` and
//! the transform box around several points (in one layer or several) in [`transform`], point snapping in `point_snap.rs` and
//! hovering, hatching and selecting filled regions in [`regions`].
pub mod ops;
+12 -8
View File
@@ -262,7 +262,7 @@ impl Session {
}
/// Every edited layer's selected points (segment ends included) other than the active one's.
fn others_moving(doc: &Document, ve: &VectorEdit) -> Vec<(NodeId, Vec<usize>)> {
pub(super) fn others_moving(doc: &Document, ve: &VectorEdit) -> Vec<(NodeId, Vec<usize>)> {
ve.others
.iter()
.filter_map(|o| {
@@ -310,7 +310,7 @@ impl Session {
let Some(b) = transform::shown_box(&doc, ve) else { return Ok(()) };
match bh {
BoxHit::Inside => {
let g = self.points_gesture(ve.id, ev.p(), b.points, vec![], None, true)?;
let g = self.points_gesture(ve.id, ev.p(), b.points, b.others, None, true)?;
self.begin_interaction("Move points")?;
self.vgesture(g);
}
@@ -320,7 +320,7 @@ impl Session {
self.vgesture(VGesture::BoxRotate { b, start_angle, angle: 0.0 });
}
BoxHit::Handle(handle) => {
let targets = Arc::new(point_snap::targets(&doc, page, Some(ve.id), &b.points, &[]));
let targets = Arc::new(point_snap::targets_moving(&doc, page, Some(ve.id), &b.points, &[], &b.others));
self.begin_interaction("Scale points")?;
self.vgesture(VGesture::BoxScale { b, handle, targets, pick });
}
@@ -428,11 +428,11 @@ impl Session {
}
/// The region of the active layer a press at `ev` would take (Move tool, `h` the network's part
/// there). Inside the transform box around points selected in their own right, the box keeps
/// its drag unless the region is already selected.
/// there). Inside the transform box around points selected in their own right (in any edited
/// layer), the box keeps its drag unless the region is already selected.
fn region_under(doc: &Document, ve: &VectorEdit, h: Option<VHit>, ev: &PointerEvent) -> Option<usize> {
let r = region_hit(doc, ve, ev.p(), ev.zoom)?;
let own = !ve.shown_vertices().is_empty() || !ve.segments.is_empty();
let own = !ve.shown_vertices().is_empty() || !ve.segments.is_empty() || ve.others_selected();
let in_box = !ev.shift && own && transform::box_hit(doc, ve, h, ev.p(), ev.zoom) == Some(BoxHit::Inside);
(!in_box || ve.regions.contains(&r)).then_some(r)
}
@@ -883,7 +883,7 @@ impl Session {
let q = b.frame.inverse() * (ev.p() + c);
let (x0, y0, x1, y1) = crate::tools::resized_box(handle, q, b.width, b.height, ev.shift, ev.alt);
let m = b.onto(x0, y0, x1, y1);
self.preview(|doc, _| transform::transform_points(doc, id, &b.points, m))?;
self.preview(|doc, _| transform::transform_box(doc, id, &b, m))?;
}
VGesture::BoxRotate { b, start_angle, .. } => {
let c = b.center();
@@ -892,7 +892,7 @@ impl Session {
angle = (angle / 15.0).round() * 15.0;
}
let m = figcraft_geom::rotate_about(Affine::IDENTITY, c, -angle);
self.preview(|doc, _| transform::transform_points(doc, id, &b.points, m))?;
self.preview(|doc, _| transform::transform_box(doc, id, &b, m))?;
self.tool.overlay.cursor = Cursor::Rotate((ev.p() - c).atan2().to_degrees());
self.vgesture(VGesture::BoxRotate { b, start_angle, angle });
}
@@ -1010,6 +1010,10 @@ impl Session {
set_state(self, |e| {
e.vertices.clear();
e.segments.clear();
for o in &mut e.others {
o.vertices.clear();
o.segments.clear();
}
})?;
}
}
+48 -12
View File
@@ -1,11 +1,11 @@
//! The transform box of vector edit mode. With two or more points selected (Move tool) a box in the
//! vector's own axes wraps them: its corners and edges scale them (Shift keeps proportions, Alt
//! scales from the centre), dragging just outside a corner rotates them about its centre (Shift:
//! 15° steps) and dragging inside it moves them; each drag is one undo step (the gestures live in
//! `pointer.rs`). Commands make the same edits for the Design panel, the control channel and MCP,
//! vector's own axes wraps them (in the page's axes when they are in several layers): its corners
//! and edges scale them (Shift keeps proportions, Alt scales from the centre), dragging just
//! outside a corner rotates them about its centre (Shift: 15° steps) and dragging inside it moves
//! them; each drag is one undo step for every layer (the gestures live in `pointer.rs`). Commands make the same edits for the Design panel, the control channel and MCP,
//! and while points are selected the flip and 90°/180° rotation commands act on them.
use figcraft_doc::Document;
use figcraft_doc::{Document, NodeId};
use figcraft_geom::{Affine, Point};
use serde_json::{Value, json};
@@ -19,16 +19,23 @@ use crate::{Result, Session, bad, spec};
const EPS: f64 = 1e-9;
/// The selected points' box: the page transform of its top-left corner (axes along the vector's
/// own), its size in those axes and the points in it.
/// own), its size in those axes and the points in it: the active layer's, and other edited
/// layers' in `others`.
#[derive(Clone, Debug, PartialEq)]
pub struct PointBox {
pub frame: Affine,
pub width: f64,
pub height: f64,
pub points: Vec<usize>,
pub others: Vec<(NodeId, Vec<usize>)>,
}
impl PointBox {
/// How many points the box wraps, in every layer.
pub fn point_count(&self) -> usize {
self.points.len() + self.others.iter().map(|(_, v)| v.len()).sum::<usize>()
}
/// Corners in page coordinates (tl, tr, br, bl).
pub fn corners(&self) -> [Point; 4] {
let (f, w, h) = (self.frame, self.width, self.height);
@@ -62,17 +69,37 @@ pub fn point_box(doc: &Document, ve: &VectorEdit) -> Option<PointBox> {
if !(x0.is_finite() && y0.is_finite() && width.is_finite() && height.is_finite()) {
return None;
}
Some(PointBox { frame: doc.world_transform(ve.id) * Affine::translate((x0, y0)), width, height, points })
Some(PointBox { frame: doc.world_transform(ve.id) * Affine::translate((x0, y0)), width, height, points, others: vec![] })
}
/// The box of the selected points of every edited layer, in the page's axes (the layers' own may
/// differ).
fn layers_box(doc: &Document, ve: &VectorEdit) -> Option<PointBox> {
let points = selected_points(network(doc, ve.id)?, ve);
let others = Session::others_moving(doc, ve);
let page = |id: NodeId, verts: &[usize]| -> Vec<Point> {
let w = doc.world_transform(id);
network(doc, id).map(|n| verts.iter().filter_map(|v| n.vertices.get(*v)).map(|v| w * v.point()).collect()).unwrap_or_default()
};
let all = page(ve.id, &points).into_iter().chain(others.iter().flat_map(|(id, v)| page(*id, v)));
let (x0, y0, x1, y1) = all.fold((f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |(a, b, c, d), p| {
(a.min(p.x), b.min(p.y), c.max(p.x), d.max(p.y))
});
let (width, height) = (x1 - x0, y1 - y0);
if !(x0.is_finite() && y0.is_finite() && width.is_finite() && height.is_finite()) {
return None;
}
Some(PointBox { frame: Affine::translate((x0, y0)), width, height, points, others })
}
/// The box drawn and dragged on the canvas: the Move tool, no open pen path, and two or more
/// points that aren't all in one place, all in the active layer.
/// shortcut: with points selected in several layers there is no box (drags, nudges and the Design panel still reach them all); wrap them all if asked for.
/// points that aren't all in one place, in any of the edited layers.
pub fn shown_box(doc: &Document, ve: &VectorEdit) -> Option<PointBox> {
if ve.tool != VTool::Move || ve.anchor.is_some() || ve.others_selected() {
if ve.tool != VTool::Move || ve.anchor.is_some() {
return None;
}
point_box(doc, ve).filter(|b| b.points.len() >= 2 && (b.width > EPS || b.height > EPS))
let b = if ve.others_selected() { layers_box(doc, ve) } else { point_box(doc, ve) };
b.filter(|b| b.point_count() >= 2 && (b.width > EPS || b.height > EPS))
}
/// What the transform box does under the pointer.
@@ -115,12 +142,21 @@ pub fn box_hit(doc: &Document, ve: &VectorEdit, h: Option<VHit>, p: Point, zoom:
/// Map points `verts` of vector `id` (and the handles at them) through page transform `m`, then
/// re-fit the vector.
pub fn transform_points(doc: &mut Document, id: figcraft_doc::NodeId, verts: &[usize], m: Affine) -> Result<()> {
pub fn transform_points(doc: &mut Document, id: NodeId, verts: &[usize], m: Affine) -> Result<()> {
let w = doc.world_transform(id);
let local = w.inverse() * m * w;
edit(doc, id, |net| ops::transform_vertices(net, verts, local))
}
/// Map the points of box `b` (`id`: the active layer) in every layer through page transform `m`.
pub fn transform_box(doc: &mut Document, id: NodeId, b: &PointBox, m: Affine) -> Result<()> {
transform_points(doc, id, &b.points, m)?;
for (o, verts) in &b.others {
transform_points(doc, *o, verts, m)?;
}
Ok(())
}
/// Transform the selected points by `f(box)` (page space) when editing with points selected and
/// the command names no layers (`p` has no `ids` or `id`); `None` otherwise (it then acts on
/// layers).
@@ -307,3 +307,122 @@ fn edit_mode_transform_commands_survive_junk() {
}
}
}
/// Two edited layers turned differently: `a` a 100×100 square at the origin turned -20°, `b` one
/// at (200, 0) turned 30° (the active layer), with two of `a`'s corners and all of `b`'s points
/// selected. Neither layer's axes are the page's. Snapping (the pixel grid too) is off so drags
/// land exactly.
fn two_layers(s: &mut Session) -> (NodeId, NodeId) {
let mut square = |x: f64| crate::params::id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"]).unwrap();
let (a, b) = (square(0.0), square(200.0));
ex(s, "node.set", json!({"id": a, "props": {"rotation": -20}}));
ex(s, "node.set", json!({"id": b, "props": {"rotation": 30}}));
ex(s, "view.snapGeometry", json!({"on": false}));
ex(s, "view.snapObjects", json!({"on": false}));
ex(s, "view.snapPixel", json!({"on": false}));
ex(s, "vector.edit.begin", json!({"ids": [a, b]}));
let n = net(s, a);
ex(s, "vector.edit.select", json!({"vertices": [vertex_at(&n, 0.0, 0.0), vertex_at(&n, 100.0, 100.0)]}));
ex(s, "vector.edit.select", json!({"id": b, "vertices": [0, 1, 2, 3], "add": true}));
assert_eq!(ve(s).id, b, "the layer named last is the active one");
(a, b)
}
/// Every selected point's page position, layer by layer.
fn selected_page_points(s: &Session, a: NodeId, b: NodeId) -> Vec<(NodeId, usize, Point)> {
let e = ve(s);
let parts = e.parts();
[a, b]
.iter()
.flat_map(|id| {
let part = parts.iter().find(|p| p.id == *id).unwrap();
part.vertices.iter().map(|v| (*id, *v, page_pt(s, *id, *v))).collect::<Vec<_>>()
})
.collect()
}
#[test]
fn the_box_wraps_points_selected_in_several_layers() {
let mut s = Session::new();
let (a, b) = two_layers(&mut s);
let before = selected_page_points(&s, a, b);
assert_eq!(before.len(), 6);
// On the page's axes: `b` is turned, so neither layer's own axes fit both.
let (x0, y0) = before.iter().fold((f64::INFINITY, f64::INFINITY), |(x, y), (_, _, p)| (x.min(p.x), y.min(p.y)));
let (x1, y1) = before.iter().fold((f64::NEG_INFINITY, f64::NEG_INFINITY), |(x, y), (_, _, p)| (x.max(p.x), y.max(p.y)));
let c = s.vector_edit_box().expect("a box around the points of both layers");
assert!(near(c[0], pt(x0, y0)) && near(c[2], pt(x1, y1)), "{c:?} vs ({x0}, {y0})–({x1}, {y1})");
// The bottom-right corner dragged to twice the width scales every point in both layers.
drag(&mut s, (x1, y1), (x0 + 2.0 * (x1 - x0), y1));
for (id, v, p) in &before {
let q = page_pt(&s, *id, *v);
assert!(near(q, pt(x0 + 2.0 * (p.x - x0), p.y)), "point {v} of {id:?}: {p:?} → {q:?}");
}
s.undo().unwrap();
for (id, v, p) in &before {
assert!(near(page_pt(&s, *id, *v), *p), "one undo step brings back point {v} of {id:?}");
}
// From just outside the top-right corner, a quarter turn clockwise about the box's centre.
let ctr = pt((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let from = pt(x1 + 10.0, y0 - 10.0);
let d = from - ctr;
drag(&mut s, (from.x, from.y), (ctr.x - d.y, ctr.y + d.x));
for (id, v, p) in &before {
let r = *p - ctr;
let q = page_pt(&s, *id, *v);
assert!(near(q, pt(ctr.x - r.y, ctr.y + r.x)), "point {v} of {id:?}: {p:?} → {q:?}");
}
s.undo().unwrap();
for (id, v, p) in &before {
assert!(near(page_pt(&s, *id, *v), *p), "one undo step brings back point {v} of {id:?}");
}
}
#[test]
fn inside_the_box_of_several_layers_drags_and_a_click_clears_them_all() {
let mut s = Session::new();
let (a, b) = two_layers(&mut s);
let before = selected_page_points(&s, a, b);
// Clear of both networks: between the two squares, inside the box.
let doc = s.doc().unwrap().clone();
assert!(hit_any(&doc, &ve(&s), pt(150.0, 50.0), 6.0).is_none());
assert_eq!(box_hit(&doc, &ve(&s), None, pt(150.0, 50.0), 1.0), Some(BoxHit::Inside));
drag(&mut s, (150.0, 50.0), (170.0, 60.0));
for (id, v, p) in &before {
assert!(near(page_pt(&s, *id, *v), *p + Vec2::new(20.0, 10.0)), "point {v} of {id:?} moves along");
}
s.undo().unwrap();
click(&mut s, 150.0, 50.0);
assert!(ve(&s).parts().iter().all(|p| p.vertices.is_empty()), "a click inside clears every layer's points");
}
#[test]
fn inside_the_box_of_another_layer_drags_over_a_region_of_the_active_one() {
// `b`'s points selected, `a` (a filled 300 square around `b`) active with nothing selected:
// the press at the box's centre lands on `a`'s region too.
let mut s = Session::new();
let mut square = |x: f64, size: f64| {
crate::params::id_of(&ex(&mut s, "shape.rectangle", json!({"x": x, "y": x, "width": size, "height": size}))["id"]).unwrap()
};
let (a, b) = (square(0.0, 300.0), square(100.0, 50.0));
ex(&mut s, "view.snapGeometry", json!({"on": false}));
ex(&mut s, "view.snapObjects", json!({"on": false}));
ex(&mut s, "view.snapPixel", json!({"on": false}));
ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]}));
ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [0, 1, 2, 3]}));
ex(&mut s, "vector.edit.select", json!({"id": a, "add": true}));
let doc = s.doc().unwrap().clone();
let e = ve(&s);
assert!(e.id == a && e.vertices.is_empty() && e.others_selected());
assert!(hit_any(&doc, &e, pt(125.0, 125.0), 6.0).is_none());
assert!(regions::region_hit(&doc, &e, pt(125.0, 125.0), 1.0).is_some(), "a region of the active layer under the press");
assert_eq!(box_hit(&doc, &e, None, pt(125.0, 125.0), 1.0), Some(BoxHit::Inside));
let before: Vec<Point> = (0..4).map(|v| page_pt(&s, b, v)).collect();
drag(&mut s, (125.0, 125.0), (145.0, 135.0));
assert!(ve(&s).regions.is_empty(), "the region isn't selected");
for (v, p) in before.iter().enumerate() {
assert!(near(page_pt(&s, b, v), *p + Vec2::new(20.0, 10.0)), "point {v} of b moves along");
}
}
+5 -2
View File
@@ -309,11 +309,14 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
- **Estimate:** 5–10 h (video playback in the presenter is most of it; it can reuse the GIF clock).
### 13. Vector tail: none left
- **Landed (2026-10-22):** the transform box around points selected in several layers at once, in
the page's axes: its corners and edges scale them, outside a corner rotates them and inside drags
them, each in its own layer and one undo step for all (`transform_tests`).
- **Landed (2026-10-12):** several Bezier handles selected with Shift-click and dragged together,
sharp points beside curved segments rounded, a stroke cap and join per point (canvas, PNG, SVG,
PDF, outline stroke), the Pen outside edit mode connecting to its own points and continuing a
selected open path. Left: corner smoothing beyond rectangles and frames, the transform box around
points of several layers, per-point caps and joins on dashed, variable-width and brush strokes.
selected open path. Left: corner smoothing beyond rectangles and frames, per-point caps and joins
on dashed, variable-width and brush strokes.
- **Landed (2026-10-11):** boolean groups are clicked where their result is (a click in a
subtracted hole reaches what's behind; with the group or an operand selected its operands are
hit, so each can still be picked; "Select layer" lists the group only where its result is), live
+2 -1
View File
@@ -99,7 +99,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 1 | Canvas & navigation | 6 | 98% | 74% | Outline mode's Include hidden layers and Include object bounds, Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks) | 1–4 |
| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 |
| 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 |
| 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on polygons, stars and vectors; the transform box around points of several layers; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 |
| 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on polygons, stars and vectors; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 |
| 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 |
| 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 |
| 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 |
@@ -643,6 +643,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|---|---|---|
| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Area 22 added at weight 0 (breadth ~35%, ready ~20%); headline numbers unchanged (the weighted sum leaves area 22 out) |
| 2026-10-15 | minor | Localization (#263, #469, #477, #288): Turkish reviewed by a native speaker, Czech and Indonesian drafts, the Japanese draft caught up; coverage re-measured (2,177 entries; the five older drafts at 95%). Localization 39% and 29–51 h unchanged at this rounding (see [localization-parity](localization-parity.md)) |
| 2026-10-22 | minor | Vector edit mode: the transform box around points selected in several layers (in the page's axes; scale, rotate and drag in one undo step). Shapes & vector's remaining work loses that item; numbers unchanged |
| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged |
| 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) |
| 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. |
+1
View File
@@ -174,6 +174,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|---|---|---|
| 2026-10-24 | minor | Boards (M12): eight board bindings pinned (tool keys Q sticky, B shapes, X connector, M marker, U highlighter, E eraser, W stamp, and Shift+Cmd/Ctrl+Enter for the next sticky; boards only, on keys no design-file shortcut uses): 49 → 57 pinned |
| 2026-10-15 | minor | Community fixes (#262, #272, #277, #300, #308, #311, #322, #352, #363, #372, #390): first runs follow the system theme; new text is readable on the frame it is drawn in; a long pages list scrolls on its own and new pages take the current page's colour; the pen shows its handles while dragging a point out and ends its path when you leave Design; vector edit mode keeps the canvas still; a drag with the Text tool makes a fixed-size box; colour emoji; the selection highlight on the last line; Up and Down in the font picker with a live preview. Numbers unchanged |
| 2026-10-22 | minor | Vector edit mode: points selected in several layers get the transform box, in the page's axes since the layers can be turned differently; its corners and edges scale them, outside a corner rotates them and inside drags them, each layer's points in its own coordinates and one undo step for all, and a click inside clears every layer's points (`transform_tests`). Numbers unchanged |
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged |
| 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel |