diff --git a/crates/engine/src/vector_multi_behaviour_tests.rs b/crates/engine/src/vector_multi_behaviour_tests.rs index 1d2075b1..b1e6a948 100644 --- a/crates/engine/src/vector_multi_behaviour_tests.rs +++ b/crates/engine/src/vector_multi_behaviour_tests.rs @@ -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"); diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index a3fbf803..3b79dd4b 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -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; diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index 6667a209..fc6f7891 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -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)> { + pub(super) fn others_moving(doc: &Document, ve: &VectorEdit) -> Vec<(NodeId, Vec)> { 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 }); } @@ -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(); + } })?; } } diff --git a/crates/engine/src/vectoredit/transform.rs b/crates/engine/src/vectoredit/transform.rs index 1d5f27a6..ca6e59e2 100644 --- a/crates/engine/src/vectoredit/transform.rs +++ b/crates/engine/src/vectoredit/transform.rs @@ -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, + pub others: Vec<(NodeId, Vec)>, } 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::() + } + /// 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 { 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 { + let points = selected_points(network(doc, ve.id)?, ve); + let others = Session::others_moving(doc, ve); + let page = |id: NodeId, verts: &[usize]| -> Vec { + 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 { - 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, 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). diff --git a/crates/engine/src/vectoredit/transform_tests.rs b/crates/engine/src/vectoredit/transform_tests.rs index d69baf52..73a49aab 100644 --- a/crates/engine/src/vectoredit/transform_tests.rs +++ b/crates/engine/src/vectoredit/transform_tests.rs @@ -307,3 +307,93 @@ 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::>() + }) + .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"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 9b1a770c..35a71a12 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -308,11 +308,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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a1a5bf12..f914bf26 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -97,7 +97,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 | @@ -638,6 +638,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 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. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d87103f0..f89b3f67 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -82,7 +82,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 or Enter 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`) | -| 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); 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`). Not yet: the transform box around points of several layers; 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); 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`); with points selected in several layers the transform box wraps them all in the page's axes and scales, turns and drags them in one undo step (`transform_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`). 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 | | | Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653); 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; 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 | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 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 |