From 894f25c4c40973dbd634c0d5c4f920dd24c4299e Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 11 Oct 2026 20:30:38 +0700 Subject: [PATCH 1/3] M1: selection and transform behaviour suite and fixes (#133) A selection and transform behaviour suite derived from public documentation, with fixes: Alt-resizing shrinks as well as grows; groups, Scale-constrained frames and multiple selections resize each layer along its own axes keeping rotation and flip; several layers flip together past an edge; a flipped layer reads back the typed rotation; resizes land on whole pixels with the pixel grid; presses inside tiny layers move them; locked layers get no handles; a marquee can start on a top-level frame's background; select inverse skips hidden layers. Adds equal-spacing snapping with labelled gaps and Cmd/Ctrl to ignore constraints while resizing a frame. --- ROADMAP.md | 7 +- crates/engine/src/cmd/edit.rs | 3 +- crates/engine/src/lib.rs | 4 + .../engine/src/selection_behaviour_tests.rs | 277 +++++++++++++ crates/engine/src/snap.rs | 82 +++- crates/engine/src/tools.rs | 246 ++++++----- .../engine/src/transform_behaviour_tests.rs | 388 ++++++++++++++++++ crates/geom/src/tests.rs | 8 + crates/geom/src/transform.rs | 5 +- crates/layout/src/lib.rs | 31 +- crates/ui-egui/src/canvas.rs | 15 +- docs/mcp.md | 13 +- docs/target-app-parity.md | 7 +- docs/ui-parity.md | 13 +- 14 files changed, 949 insertions(+), 150 deletions(-) create mode 100644 crates/engine/src/selection_behaviour_tests.rs create mode 100644 crates/engine/src/transform_behaviour_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index f306bed..b2ce7d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~173–310 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -56,7 +56,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Dimension | Ready | Hours | Doc | |---|---|---|---| -| Features (depth) | 57% (core editor 67%) | 60–115 | [target-app-parity](docs/target-app-parity.md) | +| Features (depth) | 57% (core editor 68%) | 60–115 | [target-app-parity](docs/target-app-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | File formats | 55% (`.fig` read best effort, no write) | 8–15 | [file-format-parity](docs/file-format-parity.md) | | Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) | @@ -73,7 +73,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Area | Breadth | Ready | Hours | Doc | |---|---|---|---|---| | Canvas & navigation | 95% | 72% | 3–6 | [ui-parity](docs/ui-parity.md) | -| Selection & transform | 95% | 68% | 6–10 | [ui-parity](docs/ui-parity.md) | +| Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) | | Layers panel | 95% | 78% | 2–4 | [ui-parity](docs/ui-parity.md) | | Shapes & vector | 97% | 68% | 1–3 | [target-app-parity](docs/target-app-parity.md) | | Paint | 85% | 66% | 5–9 | [target-app-parity](docs/target-app-parity.md) | @@ -195,6 +195,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h | | 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 in the desktop app and through MCP; eight fixes: recovery copies of unsaved documents reopen after a crash or a forced quit (`file.recover`), Ctrl moves and deletes by word in text on Windows and Linux, scrubbed fields keep their value, typing on a field Tab reached edits it, Escape closes dialogs, linear gradients keep square bands, panel hints wrap, the window-close question and "Open in new window" are translated. Percentages and hours unchanged | | 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h | | 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 90b3d3b..64a27f7 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -677,12 +677,13 @@ fn select_all(s: &mut Session, _p: &Value) -> Result { Ok(json!({"selection": ids})) } +/// What Select all would select, less the selection (hidden and locked layers neither). fn select_inverse(s: &mut Session, _p: &Value) -> Result { let doc = s.doc()?; let sel = s.selection(); let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?); let ids: Vec = - doc.get(parent)?.children.iter().copied().filter(|c| !sel.contains(c) && doc.node(*c).is_some_and(|n| !n.locked)).collect(); + doc.get(parent)?.children.iter().copied().filter(|c| !sel.contains(c) && doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect(); s.set_selection(ids.clone()); Ok(json!({"selection": ids})) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 19284fd..38caf70 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1309,6 +1309,8 @@ mod section_contents_tests; #[cfg(test)] mod section_tests; #[cfg(test)] +mod selection_behaviour_tests; +#[cfg(test)] mod selection_depth_tests; #[cfg(test)] mod shapebuilder_strokes_tests; @@ -1331,6 +1333,8 @@ mod text_style_picker_tests; #[cfg(test)] mod tiff_tests; #[cfg(test)] +mod transform_behaviour_tests; +#[cfg(test)] mod variable_tests; #[cfg(test)] mod variables_editor_tests; diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs new file mode 100644 index 0000000..b65b793 --- /dev/null +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -0,0 +1,277 @@ +//! Selection behaviour suite, written from the public documentation of design tools: each rule +//! is a table row with the selection it implies and a one-line derivation. Where the +//! documentation is silent the row says so and pins today's behaviour. + +// The tables read best as tuples. +#![allow(clippy::type_complexity)] + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind, handle_at}; +use figcraft_doc::NodeId; +use figcraft_geom::Point; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn id(v: &Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn rect(s: &mut Session, x: f64, y: f64, parent: Option) -> NodeId { + let mut p = json!({"x": x, "y": y, "width": 40, "height": 40}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id(&ex(s, "shape.rectangle", p)) +} + +/// The layers of the scene, by name. +#[allow(dead_code)] +struct Scene { + /// Top-level frame (0,0 400×300) holding: group `g` of `a` (20,20) and `b` (80,20); `c` + /// (20,120); nested frame `n` (200,20 150×150) with `d` (220,40); boolean `u` of two + /// rectangles at (20,200) and (40,200). + f: NodeId, + g: NodeId, + a: NodeId, + b: NodeId, + c: NodeId, + n: NodeId, + d: NodeId, + u: NodeId, + /// On the page: `r` (500,0); `r2` (500,100) under the locked `lk` (same place); the hidden + /// `hid` (500,200); the empty frame `empty` (600,0 80×80). + r: NodeId, + r2: NodeId, + lk: NodeId, + hid: NodeId, + empty: NodeId, +} + +fn scene(s: &mut Session) -> Scene { + let f = id(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300}))); + let a = rect(s, 20.0, 20.0, Some(f)); + let b = rect(s, 80.0, 20.0, Some(f)); + let g = id(&ex(s, "object.group", json!({"ids": [a, b]}))); + let c = rect(s, 20.0, 120.0, Some(f)); + let n = id(&ex(s, "frame.create", json!({"x": 200, "y": 20, "width": 150, "height": 150, "parent": f}))); + // Positions are in the parent's space. + let d = rect(s, 20.0, 20.0, Some(n)); + let e1 = rect(s, 20.0, 200.0, Some(f)); + let e2 = rect(s, 40.0, 200.0, Some(f)); + let u = id(&ex(s, "bool.union", json!({"ids": [e1, e2]}))); + let r = rect(s, 500.0, 0.0, None); + let r2 = rect(s, 500.0, 100.0, None); + let lk = rect(s, 500.0, 100.0, None); + ex(s, "object.toggleLock", json!({"ids": [lk], "locked": true})); + let hid = rect(s, 500.0, 200.0, None); + ex(s, "object.toggleVisible", json!({"ids": [hid], "visible": false})); + let empty = id(&ex(s, "frame.create", json!({"x": 600, "y": 0, "width": 80, "height": 80}))); + ex(s, "select.none", json!({})); + Scene { f, g, a, b, c, n, d, u, r, r2, lk, hid, empty } +} + +#[derive(Clone, Copy, Default)] +struct Mods { + shift: bool, + cmd: bool, +} + +const NONE: Mods = Mods { shift: false, cmd: false }; +const SHIFT: Mods = Mods { shift: true, cmd: false }; +const CMD: Mods = Mods { shift: false, cmd: true }; + +fn ev(kind: PointerKind, x: f64, y: f64, m: Mods) -> PointerEvent { + let mut e = PointerEvent::at(kind, x, y, 1.0); + (e.shift, e.cmd) = (m.shift, m.cmd); + e +} + +/// A press and release without moving. +fn click(s: &mut Session, x: f64, y: f64, m: Mods) { + s.pointer(&ev(PointerKind::Down, x, y, m)).unwrap(); + s.pointer(&ev(PointerKind::Up, x, y, m)).unwrap(); +} + +fn drag(s: &mut Session, a: (f64, f64), b: (f64, f64), m: Mods) { + s.pointer(&ev(PointerKind::Down, a.0, a.1, m)).unwrap(); + s.pointer(&ev(PointerKind::Move, b.0, b.1, m)).unwrap(); + s.pointer(&ev(PointerKind::Up, b.0, b.1, m)).unwrap(); +} + +fn sorted(mut v: Vec) -> Vec { + v.sort(); + v +} + +#[test] +fn clicking_selects_by_the_documented_rules() { + let mut s = Session::new(); + let k = scene(&mut s); + // (point, modifiers, selection before, selection after, derivation) + let rows: Vec<((f64, f64), Mods, Vec, Vec, &str)> = vec![ + ((520.0, 20.0), NONE, vec![], vec![k.r], "a click selects the layer under the pointer"), + ((30.0, 30.0), NONE, vec![], vec![k.g], "a group is selected as a whole"), + ((30.0, 130.0), NONE, vec![], vec![k.c], "a layer in a top-level frame is selected directly (frames hold their layers like the canvas)"), + ((230.0, 50.0), NONE, vec![], vec![k.n], "silent: a nested frame selects as a whole, like a group (pinned)"), + ((30.0, 210.0), NONE, vec![], vec![k.u], "a boolean group is selected as a whole"), + ((30.0, 30.0), CMD, vec![], vec![k.a], "Cmd/Ctrl-click selects the deepest layer"), + ((230.0, 50.0), CMD, vec![], vec![k.d], "Cmd/Ctrl-click reaches into nested frames"), + ((30.0, 210.0), CMD, vec![], vec![sel_operand(&s, k.u, 30.0)], "Cmd/Ctrl-click reaches a boolean's operand"), + ((90.0, 30.0), NONE, vec![k.a], vec![k.b], "with a layer selected, its siblings select at its level"), + ((520.0, 20.0), SHIFT, vec![k.c], vec![k.c, k.r], "Shift-click adds to the selection"), + ((30.0, 130.0), SHIFT, vec![k.c, k.r], vec![k.r], "Shift-click on a selected layer removes it"), + ((520.0, 120.0), NONE, vec![], vec![k.r2], "a locked layer can't be selected on the canvas: the click goes to what is under it"), + ((520.0, 220.0), NONE, vec![k.r], vec![], "a hidden layer can't be selected on the canvas (empty canvas deselects)"), + ((390.0, 290.0), NONE, vec![k.r], vec![], "the background of a top-level frame with layers is like the canvas: a click deselects"), + ((390.0, 290.0), NONE, vec![k.f], vec![k.f], "a selected top-level frame stays selected when its background is clicked"), + ((390.0, 290.0), CMD, vec![], vec![k.f], "silent: Cmd/Ctrl-click on a frame's background selects the frame (the deepest layer there)"), + ((640.0, 40.0), NONE, vec![], vec![k.empty], "an empty top-level frame is selected by a click on it"), + ]; + for (p, m, before, after, why) in rows { + ex(&mut s, "select.set", json!({"ids": before})); + click(&mut s, p.0, p.1, m); + assert_eq!(sorted(s.selection()), sorted(after), "{why}"); + } +} + +/// The operand of boolean `u` under x (its children are the two rectangles). +fn sel_operand(s: &Session, u: NodeId, x: f64) -> NodeId { + let doc = s.doc().unwrap(); + let kids = &doc.node(u).unwrap().children; + // The later child paints on top: (40,200) covers x ≥ 40, else the first. + if x >= 40.0 { kids[1] } else { kids[0] } +} + +#[test] +fn double_click_enters_one_level() { + let mut s = Session::new(); + let k = scene(&mut s); + let comp_child = rect(&mut s, 800.0, 0.0, None); + let comp = id(&ex(&mut s, "component.create", json!({"ids": [comp_child]}))); + let inst = id(&ex(&mut s, "instance.create", json!({"component": comp, "x": 300, "y": 200, "parent": k.f}))); + let inst_child = s.doc().unwrap().node(inst).unwrap().children[0]; + // (point, selection before, selection after, derivation) + let rows: Vec<((f64, f64), Vec, Vec, &str)> = vec![ + ((30.0, 30.0), vec![k.g], vec![k.a], "double-click on a group selects the layer inside it"), + ((230.0, 50.0), vec![k.n], vec![k.d], "double-click on a nested frame selects the layer inside it"), + ((30.0, 210.0), vec![k.u], vec![sel_operand(&s, k.u, 30.0)], "double-click on a boolean group selects its operand"), + ((320.0, 220.0), vec![inst], vec![inst_child], "double-click on an instance selects the layer inside it"), + ((30.0, 30.0), vec![], vec![k.a], "a double-click's first click selects the group, the second enters it"), + ]; + for (p, before, after, why) in rows { + ex(&mut s, "select.set", json!({"ids": before})); + click(&mut s, p.0, p.1, NONE); + s.pointer(&ev(PointerKind::DoubleClick, p.0, p.1, NONE)).unwrap(); + assert_eq!(s.selection(), after, "{why}"); + } + // A selected shape: vector edit mode. + ex(&mut s, "select.set", json!({"ids": [k.r]})); + s.pointer(&ev(PointerKind::DoubleClick, 520.0, 20.0, NONE)).unwrap(); + assert_eq!(s.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(k.r), "double-click on a shape edits its points"); +} + +#[test] +fn marquee_selects_what_it_touches() { + let mut s = Session::new(); + let k = scene(&mut s); + // (from, to, modifiers, selection before, selection after, derivation) + let rows: Vec<((f64, f64), (f64, f64), Mods, Vec, Vec, &str)> = vec![ + ((480.0, -20.0), (505.0, 10.0), NONE, vec![], vec![k.r], "a marquee selects layers it touches, not only those it contains"), + ((480.0, -20.0), (560.0, 260.0), NONE, vec![], vec![k.r, k.r2], "locked and hidden layers aren't selected by a marquee"), + ( + (-10.0, -10.0), + (450.0, 40.0), + NONE, + vec![], + vec![], + "from the canvas, a top-level frame is selected only when the marquee covers all of it", + ), + ((-10.0, -10.0), (450.0, 310.0), NONE, vec![], vec![k.f], "a marquee covering a top-level frame selects the frame"), + ( + (390.0, 290.0), + (25.0, 125.0), + NONE, + vec![], + vec![k.c, k.n, k.u], + "starting on a top-level frame's background selects the layers in it the marquee touches", + ), + ( + (450.0, 290.0), + (25.0, 25.0), + CMD, + vec![], + vec![k.a, k.b, k.c, k.d, k.u], + "silent: Cmd/Ctrl-marquee selects the innermost layers it touches (pinned)", + ), + ((480.0, -20.0), (505.0, 10.0), SHIFT, vec![k.c], vec![k.c, k.r], "Shift-marquee adds to the selection"), + ((480.0, -20.0), (505.0, 10.0), SHIFT, vec![k.c, k.r], vec![k.c], "Shift-marquee takes out what was selected and is touched again"), + ]; + for (a, b, m, before, after, why) in rows { + ex(&mut s, "select.set", json!({"ids": before})); + drag(&mut s, a, b, m); + assert_eq!(sorted(s.selection()), sorted(after), "{why}"); + } +} + +#[test] +fn keyboard_selection() { + let mut s = Session::new(); + let k = scene(&mut s); + let page_layers = |s: &Session| s.doc().unwrap().node(s.page().unwrap()).unwrap().children.clone(); + let visible_unlocked: Vec = page_layers(&s).into_iter().filter(|i| ![k.lk, k.hid].contains(i)).collect(); + // (command, selection before, selection after, derivation) + let rows: Vec<(&str, Vec, Vec, &str)> = vec![ + ("select.children", vec![k.g], vec![k.a, k.b], "Enter selects the children"), + ("select.parent", vec![k.a], vec![k.g], "Shift+Enter selects the parent"), + ("select.parent", vec![k.f], vec![k.f], "silent: Shift+Enter on a top-level layer keeps it (pinned)"), + ("select.next", vec![k.b], vec![k.a], "Tab selects the next layer down the layers list"), + ("select.previous", vec![k.a], vec![k.b], "Shift+Tab selects the previous one"), + ("select.all", vec![k.a], vec![k.a, k.b], "Select all selects the selection's siblings"), + ("select.all", vec![], visible_unlocked.clone(), "with nothing selected, all layers on the page (hidden and locked ones can't be selected)"), + ( + "select.inverse", + vec![k.r], + visible_unlocked.iter().copied().filter(|i| *i != k.r).collect(), + "Select inverse: the other layers Select all would select", + ), + ]; + for (cmd, before, after, why) in rows { + ex(&mut s, "select.set", json!({"ids": before})); + ex(&mut s, cmd, json!({})); + assert_eq!(sorted(s.selection()), sorted(after), "{why}"); + } + // Esc steps out to the parent, then deselects. + ex(&mut s, "select.set", json!({"ids": [k.a]})); + s.escape().unwrap(); + assert_eq!(s.selection(), vec![k.g], "Esc selects the parent"); + s.escape().unwrap(); + assert_eq!(s.selection(), vec![k.f]); + s.escape().unwrap(); + assert_eq!(s.selection(), Vec::::new(), "Esc on a top-level layer deselects"); +} + +#[test] +fn locked_layers_selected_in_the_layers_panel_stay_put() { + let mut s = Session::new(); + let k = scene(&mut s); + // Selectable from the layers panel (select.set), but the canvas offers no handles and a drag + // that starts on another selected layer leaves it where it is. + ex(&mut s, "select.set", json!({"ids": [k.lk]})); + let doc = s.doc().unwrap().clone(); + let corner = Point::new(540.0, 140.0); + assert_eq!(handle_at(&doc, &[k.lk], corner, 1.0), None, "a locked layer has no resize handles"); + assert!(handle_at(&doc, &[k.r2], corner, 1.0).is_some(), "the same box unlocked has"); + ex(&mut s, "select.set", json!({"ids": [k.lk, k.r]})); + drag(&mut s, (520.0, 20.0), (530.0, 30.0), NONE); + let doc = s.doc().unwrap(); + assert_eq!(doc.world_bounds(k.r).unwrap().origin(), Point::new(510.0, 10.0), "the unlocked layer moves"); + assert_eq!(doc.world_bounds(k.lk).unwrap().origin(), Point::new(500.0, 100.0), "the locked one doesn't"); + // Layers inside a locked layer are locked too. + ex(&mut s, "object.toggleLock", json!({"ids": [k.g], "locked": true})); + assert_eq!(handle_at(s.doc().unwrap(), &[k.a], Point::new(60.0, 60.0), 1.0), None); +} diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 258a03f..eebc0e4 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -52,6 +52,9 @@ pub struct Targets { pub moving: Option, } +/// A gap along a row: from, to, and where across it is drawn. +type Gap = (f64, f64, f64); + impl Targets { /// Gather snap lines from the siblings of `moving` (and the parent frame), page guides and /// layout grids, excluding the moving nodes and their descendants. Inside a rotated frame they @@ -156,11 +159,84 @@ impl Targets { } /// Snap the moving layers moved by page vector `d`: the correction to add to it (a page - /// vector) and the guides to draw. + /// vector) and the guides to draw. Besides lines, the box snaps to equal spacing with the + /// layers beside it (whichever is nearer on each axis), and equal gaps show labelled. pub fn snap_move(&self, d: Vec2, threshold: f64) -> (Vec2, Vec) { let Some(b) = self.moving else { return (Vec2::ZERO, Vec::new()) }; - let (dx, dy, guides) = self.snap_rect(b + self.to_space_vec(d), threshold); - (Vec2::new(dx, dy), guides) + let r = b + self.to_space_vec(d); + let (xs, ys) = rect_spans(r); + let (mut dx, mut dy, _) = self.snap_spans(&xs, &ys, threshold); + for (horizontal, slot) in [(true, &mut dx), (false, &mut dy)] { + if let Some(e) = self.equal_spacing(r, threshold, horizontal) + && slot.is_none_or(|v| e.abs() < v.abs()) + { + *slot = Some(e); + } + } + let c = Vec2::new(dx.unwrap_or(0.0), dy.unwrap_or(0.0)); + let at = r + c; + let mut guides = self.guides_for(at); + guides.extend(self.spacing_guides(at, true)); + guides.extend(self.spacing_guides(at, false)); + (self.to_page_vec(c), self.to_page(guides)) + } + + /// The ways `r` can be spaced equally in its row (`horizontal`) or column, worked out as a row + /// (a column's boxes are transposed): centred between its nearest neighbours, as far after the + /// one before it as that one is from the next, or as far before the one after it. Each: how far + /// `r` would move along the row, and the two gaps (from, to, across) that are equal then. + fn spacings(&self, r: Rect, horizontal: bool) -> Vec<(f64, [Gap; 2])> { + let t = |b: Rect| if horizontal { b } else { Rect::new(b.y0, b.x0, b.y1, b.x1) }; + let r = t(r); + let row = self.boxes.iter().map(|b| t(*b)).filter(|b| b.y0 < r.y1 && b.y1 > r.y0); + let (mut before, mut after): (Vec, Vec) = row.partition(|b| b.center().x < r.center().x); + before.sort_by(|a, b| b.x1.total_cmp(&a.x1)); + after.sort_by(|a, b| a.x0.total_cmp(&b.x0)); + // Across the middle of what two boxes share. + let mid = |a: &Rect, b: &Rect| (a.y0.max(b.y0) + a.y1.min(b.y1)) / 2.0; + let w = r.width(); + let mut out = Vec::new(); + if let (Some(l), Some(n)) = (before.first(), after.first()) + && n.x0 - l.x1 > w + { + let x0 = (l.x1 + n.x0 - w) / 2.0; + out.push((x0, [(l.x1, x0, mid(l, &r)), (x0 + w, n.x0, mid(&r, n))])); + } + if let (Some(l), Some(l2)) = (before.first(), before.get(1)) + && l.x0 > l2.x1 + { + let x0 = l.x1 + (l.x0 - l2.x1); + out.push((x0, [(l2.x1, l.x0, mid(l2, l)), (l.x1, x0, mid(l, &r))])); + } + if let (Some(n), Some(n2)) = (after.first(), after.get(1)) + && n2.x0 > n.x1 + { + let x0 = n.x0 - (n2.x0 - n.x1) - w; + out.push((x0, [(x0 + w, n.x0, mid(&r, n)), (n.x1, n2.x0, mid(n, n2))])); + } + out.into_iter().map(|(x0, gaps)| (x0 - r.x0, gaps)).filter(|(d, _)| d.is_finite()).collect() + } + + /// The correction (snap space, along the row or column) that spaces `r` equally, if one is in + /// reach. + fn equal_spacing(&self, r: Rect, threshold: f64, horizontal: bool) -> Option { + self.spacings(r, horizontal).into_iter().map(|(d, _)| d).filter(|d| d.abs() <= threshold).min_by(|a, b| a.abs().total_cmp(&b.abs())) + } + + /// Labelled lines across the gaps that are equal around `r` in its row or column. + fn spacing_guides(&self, r: Rect, horizontal: bool) -> Vec { + let mut out: Vec = Vec::new(); + for (_, gaps) in self.spacings(r, horizontal).into_iter().filter(|(d, _)| d.abs() < 1e-6) { + for (from, to, across) in gaps { + let (p, q) = + if horizontal { (Point::new(from, across), Point::new(to, across)) } else { (Point::new(across, from), Point::new(across, to)) }; + let line = Guide { label: Some(crate::distance::label(to - from)), ..Guide::new(p, q) }; + if !out.contains(&line) { + out.push(line); + } + } + } + out } /// Page move `d` adjusted so that the moving layers' box keeps its origin on whole pixels of diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index d5d71cc..28ac26c 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -495,9 +495,15 @@ fn shared_rotation(doc: &Document, ids: &[NodeId]) -> Option { (a.is_finite() && !axis_aligned && angles.all(same)).then_some(a) } -/// Which handle (if any) is under `p`. +/// Whether layer `id` is locked or inside a locked layer: it can be selected in the layers panel +/// but not moved or resized on the canvas. +pub fn locked_in(doc: &Document, id: NodeId) -> bool { + std::iter::once(id).chain(doc.ancestors(id)).any(|a| doc.node(a).is_some_and(|n| n.locked)) +} + +/// Which handle (if any) is under `p`. A selection with a locked layer has none. pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option { - if sel.is_empty() { + if sel.is_empty() || sel.iter().any(|i| locked_in(doc, *i)) { return None; } let corners = selection_box(doc, sel)?; @@ -506,27 +512,48 @@ pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option< handle_at_corners(&corners, p, zoom, is_line) } +/// A box whose sides are both shorter than this on screen (pixels) would be covered by its +/// handles: the canvas draws just its outline (the handles still answer just outside it). +pub const SMALL_BOX_PX: f64 = 16.0; + +/// The shorter and longer sides, in screen pixels, of the box with `corners` (tl, tr, br, bl). +pub fn box_sides_px(corners: &[Point; 4], zoom: f64) -> (f64, f64) { + let [a, b, _, d] = corners; + let (w, h) = ((*b - *a).hypot() * zoom, (*d - *a).hypot() * zoom); + (w.min(h), w.max(h)) +} + /// Which handle of the box with `corners` (tl, tr, br, bl in page coordinates) is under `p`; /// `is_line` keeps only the two end handles. pub fn handle_at_corners(corners: &[Point; 4], p: Point, zoom: f64, is_line: bool) -> Option { let tol = HANDLE_PX / zoom.max(1e-6); - for (i, c) in corners.iter().enumerate() { - if is_line && (i == 3 || i == 2) { - continue; - } - if (*c - p).hypot() <= tol * 1.6 { - return Some(Handle::Corner(i as u8)); - } + // Inside the box, handles reach in at most a quarter of its shorter side, so that a press in + // the middle of a small layer moves it; outside it they reach as far as ever. + let inner = if !is_line && in_quad(corners, p) { box_sides_px(corners, zoom).0 / 4.0 / zoom.max(1e-6) } else { f64::INFINITY }; + let (corner_reach, edge_reach) = ((tol * 1.6).min(inner), tol.min(inner)); + // The nearest corner, then the nearest edge, in reach (a small box's reaches overlap). + let nearest = |hits: &mut dyn Iterator| hits.min_by(|a, b| a.0.total_cmp(&b.0)).map(|(_, h)| h); + let corner = nearest( + &mut corners + .iter() + .enumerate() + .filter(|(i, _)| !(is_line && *i >= 2)) + .map(|(i, c)| ((*c - p).hypot(), Handle::Corner(i as u8))) + .filter(|(d, _)| *d <= corner_reach), + ); + if corner.is_some() { + return corner; } if !is_line { - for i in 0..4 { - let (Some(a), Some(b)) = (corners.get(i), corners.get((i + 1) % 4)) else { continue }; - let seg = figcraft_geom::kurbo::Line::new(*a, *b); - use figcraft_geom::kurbo::ParamCurveNearest; - let near = seg.nearest(p, 0.001); - if near.distance_sq.sqrt() <= tol && near.t > 0.05 && near.t < 0.95 { - return Some(Handle::Edge(i as u8)); - } + use figcraft_geom::kurbo::ParamCurveNearest; + let edge = nearest(&mut (0..4).filter_map(|i| { + let (a, b) = (corners.get(i)?, corners.get((i + 1) % 4)?); + let near = figcraft_geom::kurbo::Line::new(*a, *b).nearest(p, 0.001); + let d = near.distance_sq.sqrt(); + (d <= edge_reach && near.t > 0.05 && near.t < 0.95).then_some((d, Handle::Edge(i as u8))) + })); + if edge.is_some() { + return edge; } } // Rotation zones: just outside each corner. @@ -565,107 +592,46 @@ pub(crate) fn handle_cursor(c: &[Point; 4], h: Handle) -> Cursor { } /// The box (x0, y0, x1, y1) a `w0`×`h0` handle box becomes when `handle` is dragged to `q`, all in -/// the box's own axes: `keep` constrains proportions, `from_center` resizes about the centre. -/// Edges that cross over come back reversed (x1 < x0 or y1 < y0: a flip). +/// the box's own axes: `keep` constrains proportions, `from_center` resizes about the centre (the +/// opposite side mirrors the dragged one). Edges that cross over come back reversed (x1 < x0 or +/// y1 < y0: a flip). pub(crate) fn resized_box(handle: Handle, q: Point, w0: f64, h0: f64, keep: bool, from_center: bool) -> (f64, f64, f64, f64) { let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, w0, h0); - match handle { - Handle::Corner(0) => { - x0 = q.x; - y0 = q.y; - } - Handle::Corner(1) => { - x1 = q.x; - y0 = q.y; - } - Handle::Corner(2) => { - x1 = q.x; - y1 = q.y; - } - Handle::Corner(_) => { - x0 = q.x; - y1 = q.y; - } - Handle::Edge(0) => y0 = q.y, - Handle::Edge(1) => x1 = q.x, - Handle::Edge(2) => y1 = q.y, - Handle::Edge(_) => x0 = q.x, - Handle::Rotate(_) => {} + // The dragged sides: x0 or x1 (`Some(false)` / `Some(true)`), y0 or y1. + let (sx, sy) = match handle { + Handle::Corner(0) => (Some(false), Some(false)), + Handle::Corner(1) => (Some(true), Some(false)), + Handle::Corner(2) => (Some(true), Some(true)), + Handle::Corner(_) => (Some(false), Some(true)), + Handle::Edge(0) => (None, Some(false)), + Handle::Edge(1) => (Some(true), None), + Handle::Edge(2) => (None, Some(true)), + Handle::Edge(_) => (Some(false), None), + Handle::Rotate(_) => (None, None), + }; + match sx { + Some(false) => (x0, x1) = (q.x, if from_center { w0 - q.x } else { w0 }), + Some(true) => (x0, x1) = (if from_center { w0 - q.x } else { 0.0 }, q.x), + None => {} } - if keep && w0 > 1e-9 && h0 > 1e-9 { - let (sx, sy) = ((x1 - x0) / w0, (y1 - y0) / h0); - let k = match handle { - Handle::Edge(0) | Handle::Edge(2) => sy, - Handle::Edge(_) => sx, - _ => { - if sx.abs() > sy.abs() { - sx - } else { - sy - } - } + match sy { + Some(false) => (y0, y1) = (q.y, if from_center { h0 - q.y } else { h0 }), + Some(true) => (y0, y1) = (if from_center { h0 - q.y } else { 0.0 }, q.y), + None => {} + } + if keep && w0 > 1e-9 && h0 > 1e-9 && !matches!(handle, Handle::Rotate(_)) { + // One factor for both axes: the dragged axis's (a corner: the one that moved the most), + // about the point that stays put (the opposite handle, or the centre). + let (kx, ky) = ((x1 - x0) / w0, (y1 - y0) / h0); + let k = match (sx, sy) { + (None, _) => ky, + (_, None) => kx, + _ if kx.abs() > ky.abs() => kx, + _ => ky, }; - let (nw, nh) = (w0 * k, h0 * k); - match handle { - Handle::Corner(0) => { - x0 = w0 - nw; - y0 = h0 - nh; - } - Handle::Corner(1) => { - x1 = nw; - y0 = h0 - nh; - } - Handle::Corner(2) => { - x1 = nw; - y1 = nh; - } - Handle::Corner(_) => { - x0 = w0 - nw; - y1 = nh; - } - Handle::Edge(0) | Handle::Edge(2) => { - let c = w0 / 2.0; - x0 = c - nw / 2.0; - x1 = c + nw / 2.0; - if matches!(handle, Handle::Edge(0)) { - y0 = h0 - nh; - } else { - y1 = nh; - } - } - Handle::Rotate(_) => {} - Handle::Edge(_) => { - let c = h0 / 2.0; - y0 = c - nh / 2.0; - y1 = c + nh / 2.0; - if matches!(handle, Handle::Edge(3)) { - x0 = w0 - nw; - } else { - x1 = nw; - } - } - } - } - if from_center { - let (cx, cy) = (w0 / 2.0, h0 / 2.0); - let hw = ((x1 - x0) / 2.0).abs().max((cx - x0).abs().max((x1 - cx).abs())); - let hh = ((y1 - y0) / 2.0).abs().max((cy - y0).abs().max((y1 - cy).abs())); - match handle { - Handle::Edge(0) | Handle::Edge(2) => { - y0 = cy - hh; - y1 = cy + hh; - } - Handle::Edge(_) => { - x0 = cx - hw; - x1 = cx + hw; - } - _ => { - x0 = cx - hw; - x1 = cx + hw; - y0 = cy - hh; - y1 = cy + hh; - } - } + let pivot = if from_center { Point::new(w0 / 2.0, h0 / 2.0) } else { handle_point(opposite(handle), w0, h0) }; + (x0, x1) = (pivot.x * (1.0 - k), pivot.x + (w0 - pivot.x) * k); + (y0, y1) = (pivot.y * (1.0 - k), pivot.y + (h0 - pivot.y) * k); } (x0, y0, x1, y1) } @@ -928,9 +894,17 @@ impl Session { }; return Ok(()); } - // A frame's name label picks the frame itself. - let target = label_at(&doc, page, &self.tool.labels, ev.p()) - .or_else(|| self.hit(&doc, page, ev.p(), ev.tol() * 2.0).map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))); + // A frame's name label picks the frame itself. The background of a top-level frame with + // layers in it is like the canvas (a press starts a marquee inside it) unless the frame is + // selected; an empty frame is picked and dragged. + let board_background = |leaf: NodeId| { + !sel.contains(&leaf) && doc.node(leaf).is_some_and(|n| n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty()) + }; + let target = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| { + self.hit(&doc, page, ev.p(), ev.tol() * 2.0) + .filter(|leaf| ev.cmd || !board_background(*leaf)) + .map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)) + }); match target { Some(target) => { // Clicking inside the current selection's box keeps it (drag them all). @@ -954,7 +928,11 @@ impl Session { if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) { return Ok(()); } - let ids = roots(&doc, &new_sel); + // Locked layers selected in the layers panel stay put. + let ids: Vec = roots(&doc, &new_sel).into_iter().filter(|i| !locked_in(&doc, *i)).collect(); + if ids.is_empty() { + return Ok(()); + } let targets = Targets::gather(&doc, page, &ids); self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" })?; self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false }; @@ -1225,6 +1203,11 @@ impl Session { let _ = start; // In the handle box's own axes (one layer: its local space). let (frame, w0, h0) = r.frame; + // The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool + // scales by the factor as dragged). + if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) { + p = Point::new(p.x.round(), p.y.round()); + } let q = frame.inverse() * p; let is_line = r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false); if scale_tool { @@ -1261,9 +1244,15 @@ impl Session { let nodes = r.nodes.clone(); let single = r.single; let handle = r.handle; + // Cmd/Ctrl on a frame: its children ignore their constraints and stay where they are. + let ignore_constraints = ev.cmd; self.preview(|doc, _| { if single { let Some((id, world, _, _)) = nodes.first() else { return Ok(()) }; + let pinned: Vec<(NodeId, Affine)> = match doc.node(*id) { + Some(n) if ignore_constraints && n.kind.is_frame_like() => n.children.iter().map(|c| (*c, doc.world_transform(*c))).collect(), + _ => Vec::new(), + }; let mut t = *world * Affine::translate((nx0, ny0)); if flip_x { t *= Affine::new([-1.0, 0.0, 0.0, 1.0, nw, 0.0]); @@ -1273,6 +1262,9 @@ impl Session { } doc.set_world_transform(*id, t)?; crate::cmd::props::resize_node(doc, *id, nw.max(0.01), nh.max(0.01))?; + for (c, w) in &pinned { + doc.set_world_transform(*c, *w)?; + } let n = doc.get_mut(*id)?; if matches!(handle, Handle::Corner(_) | Handle::Edge(1) | Handle::Edge(3)) { n.child_layout.sizing_h = Sizing::Fixed; @@ -1288,15 +1280,21 @@ impl Session { } return Ok(()); } - // Several layers: map the old box onto the new one, in the box's axes. - let (sx, sy) = (if w0 > 1e-9 { nw / w0 } else { 1.0 }, if h0 > 1e-9 { nh / h0 } else { 1.0 }); + // Several layers: map the old box onto the new one, in the box's axes. Each layer's + // centre follows the box (mirrored when it flips); each keeps its rotation and scales + // along its own axes (see `axis_factors`), turning over with the box. + let (sx, sy) = (if w0 > 1e-9 { (x1 - x0) / w0 } else { 1.0 }, if h0 > 1e-9 { (y1 - y0) / h0 } else { 1.0 }); + let mirror = Affine::new([sx.signum(), 0.0, 0.0, sy.signum(), 0.0, 0.0]); let inv = frame.inverse(); for (id, world, w, h) in &nodes { let local = inv * *world; - let o = local * Point::ZERO; - let c = local.as_coeffs(); - doc.set_world_transform(*id, frame * Affine::new([c[0], c[1], c[2], c[3], nx0 + o.x * sx, ny0 + o.y * sy]))?; - crate::cmd::props::resize_node(doc, *id, w * sx, h * sy)?; + let center = local * Point::new(w / 2.0, h / 2.0); + let (kw, kh) = figcraft_layout::axis_factors(local, sx, sy); + let (w2, h2) = (w * kw, h * kh); + let lin = (mirror * local).with_translation(Vec2::ZERO); + let origin = Point::new(x0 + center.x * sx, y0 + center.y * sy) - lin * Point::new(w2 / 2.0, h2 / 2.0); + doc.set_world_transform(*id, frame * lin.with_translation(origin))?; + crate::cmd::props::resize_node(doc, *id, w2, h2)?; } Ok(()) })?; diff --git a/crates/engine/src/transform_behaviour_tests.rs b/crates/engine/src/transform_behaviour_tests.rs new file mode 100644 index 0000000..face537 --- /dev/null +++ b/crates/engine/src/transform_behaviour_tests.rs @@ -0,0 +1,388 @@ +//! Transform behaviour suite, written from the public documentation of design tools: resizing +//! (Shift, Alt, both, flipping past an edge), rotating (Shift in 15° steps), groups and several +//! layers with different rotations or flips, frames and constraints, lines, text, nudging, the +//! pixel grid, smart guides and the transform box itself. Each rule is a table row or an assert +//! with a one-line derivation; where the documentation is silent the row says so and pins +//! today's behaviour. + +// The tables read best as tuples. +#![allow(clippy::type_complexity)] + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{Cursor, Handle, PointerEvent, PointerKind, handle_at, handle_cursor, resized_box, selection_box}; +use figcraft_doc::{NodeId, TextAutoResize}; +use figcraft_geom::{Point, Rect, is_flipped, rotation_degrees}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn id(v: &Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option) -> NodeId { + let mut p = json!({"x": x, "y": y, "width": w, "height": h}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id(&ex(s, "shape.rectangle", p)) +} + +fn set(s: &mut Session, ids: &[NodeId], props: Value) { + ex(s, "node.set", json!({"ids": ids, "props": props})); +} + +/// A session with snapping to layers off (the rows that test snapping turn it back on). +fn session() -> Session { + let mut s = Session::new(); + ex(&mut s, "view.snapObjects", json!({"on": false})); + s +} + +#[derive(Clone, Copy, Default)] +struct Mods { + shift: bool, + alt: bool, + cmd: bool, +} + +fn ev(kind: PointerKind, p: Point, m: Mods) -> PointerEvent { + let mut e = PointerEvent::at(kind, p.x, p.y, 1.0); + (e.shift, e.alt, e.cmd) = (m.shift, m.alt, m.cmd); + e +} + +/// A pointer drag from `a` to `b` (one move), as one gesture. +fn drag(s: &mut Session, a: Point, b: Point, m: Mods) { + s.pointer(&ev(PointerKind::Down, a, m)).unwrap(); + s.pointer(&ev(PointerKind::Move, b, m)).unwrap(); + s.pointer(&ev(PointerKind::Up, b, m)).unwrap(); +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().world_bounds(id).unwrap() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +fn near_rect(a: Rect, b: Rect) -> bool { + near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1) +} + +fn size(s: &Session, id: NodeId) -> (f64, f64) { + let n = s.doc().unwrap().node(id).unwrap(); + (n.width, n.height) +} + +fn rotation(s: &Session, id: NodeId) -> f64 { + rotation_degrees(s.doc().unwrap().node(id).unwrap().transform) +} + +fn flipped(s: &Session, id: NodeId) -> bool { + is_flipped(s.doc().unwrap().world_transform(id)) +} + +fn undo_steps(s: &Session) -> usize { + s.ds().unwrap().history.undo.len() +} + +const NONE: Mods = Mods { shift: false, alt: false, cmd: false }; +const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false }; +const ALT: Mods = Mods { shift: false, alt: true, cmd: false }; +const BOTH: Mods = Mods { shift: true, alt: true, cmd: false }; +const CMD: Mods = Mods { shift: false, alt: false, cmd: true }; + +#[test] +fn handle_drags_resize_by_the_documented_rules() { + // A 100×50 box; (handle, pointer in the box's axes, modifiers, new box x0 y0 x1 y1, derivation). + let rows: [(Handle, (f64, f64), Mods, (f64, f64, f64, f64), &str); 11] = [ + (Handle::Corner(2), (150.0, 80.0), NONE, (0.0, 0.0, 150.0, 80.0), "a corner moves the two edges it joins"), + (Handle::Edge(1), (150.0, 999.0), NONE, (0.0, 0.0, 150.0, 50.0), "an edge moves only its side"), + (Handle::Corner(2), (150.0, 60.0), SHIFT, (0.0, 0.0, 150.0, 75.0), "Shift keeps the proportions (following the axis dragged the most)"), + (Handle::Edge(1), (150.0, 0.0), SHIFT, (0.0, -12.5, 150.0, 62.5), "Shift on an edge scales the other axis about its middle"), + (Handle::Corner(2), (150.0, 80.0), ALT, (-50.0, -30.0, 150.0, 80.0), "Alt resizes from the centre"), + (Handle::Corner(2), (75.0, 40.0), ALT, (25.0, 10.0, 75.0, 40.0), "Alt shrinks about the centre too"), + (Handle::Edge(3), (10.0, 0.0), ALT, (10.0, 0.0, 90.0, 50.0), "Alt on an edge moves the opposite edge the other way"), + (Handle::Corner(2), (150.0, 60.0), BOTH, (-50.0, -25.0, 150.0, 75.0), "Shift+Alt: proportional, from the centre"), + (Handle::Edge(1), (-40.0, 0.0), NONE, (0.0, 0.0, -40.0, 50.0), "dragging an edge past the opposite one flips (reversed box)"), + (Handle::Corner(0), (110.0, 60.0), NONE, (110.0, 60.0, 100.0, 50.0), "a corner dragged past the opposite one flips both ways"), + (Handle::Corner(2), (-50.0, -25.0), SHIFT, (0.0, 0.0, -50.0, -25.0), "a proportional flip keeps the proportions"), + ]; + for (h, q, m, want, why) in rows { + let got = resized_box(h, Point::new(q.0, q.1), 100.0, 50.0, m.shift, m.alt); + assert!(near(got.0, want.0) && near(got.1, want.1) && near(got.2, want.2) && near(got.3, want.3), "{why}: {got:?}"); + } +} + +#[test] +fn resizing_and_flipping_one_layer() { + let mut s = session(); + // A rotated layer resizes along its own axes, keeping its rotation and the opposite corner. + let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None); + set(&mut s, &[r], json!({"rotation": 30})); + ex(&mut s, "select.set", json!({"ids": [r]})); + let world = s.doc().unwrap().world_transform(r); + let steps = undo_steps(&s); + drag(&mut s, world * Point::new(100.0, 50.0), world * Point::new(150.0, 80.0), NONE); + assert!(near(size(&s, r).0, 150.0) && near(size(&s, r).1, 80.0) && near(rotation(&s, r), 30.0), "rotated layers resize in their own axes"); + assert!((s.doc().unwrap().world_transform(r) * Point::ZERO - world * Point::ZERO).hypot() < 1e-6, "the opposite corner stays put"); + assert_eq!(undo_steps(&s), steps + 1, "one undo step per gesture"); + // Dragging the right edge past the left one flips the layer. + let f = rect(&mut s, 300.0, 0.0, 100.0, 50.0, None); + ex(&mut s, "select.set", json!({"ids": [f]})); + drag(&mut s, Point::new(400.0, 25.0), Point::new(260.0, 25.0), NONE); + assert!(flipped(&s, f) && near_rect(bounds(&s, f), Rect::new(260.0, 0.0, 300.0, 50.0)), "flipped, 40 wide, left of where it was"); + // Rotation handles: just outside a corner; Shift turns in 15° steps. + let t = rect(&mut s, 0.0, 200.0, 100.0, 100.0, None); + ex(&mut s, "select.set", json!({"ids": [t]})); + let (c, from) = (Point::new(50.0, 250.0), Point::new(110.0, 190.0)); + let a = (from - c).atan2() + 37f64.to_radians(); + let to = c + figcraft_geom::Vec2::from_angle(a) * 80.0; + drag(&mut s, from, to, NONE); + assert!(near(rotation(&s, t).abs(), 37.0), "a free turn: {}", rotation(&s, t)); + ex(&mut s, "edit.undo", json!({})); + drag(&mut s, from, to, SHIFT); + assert!(near(rotation(&s, t).abs(), 30.0), "Shift: the nearest 15° step: {}", rotation(&s, t)); +} + +#[test] +fn groups_scale_children_keeping_their_rotation_and_flip() { + let mut s = session(); + // A group of a 100×20 bar turned 90° (40..60 × -40..60) and a 50×50 square at (100, 0), its + // box 40..150 × -40..60. Stretching it to twice its width (right edge to 260): + let bar = rect(&mut s, 0.0, 0.0, 100.0, 20.0, None); + set(&mut s, &[bar], json!({"rotation": 90})); + let sq = rect(&mut s, 100.0, 0.0, 50.0, 50.0, None); + ex(&mut s, "object.flipH", json!({"ids": [sq]})); + let g = id(&ex(&mut s, "object.group", json!({"ids": [bar, sq]}))); + assert!(near_rect(bounds(&s, g), Rect::new(40.0, -40.0, 150.0, 60.0))); + ex(&mut s, "select.set", json!({"ids": [g]})); + drag(&mut s, Point::new(150.0, 10.0), Point::new(260.0, 10.0), NONE); + assert!(near_rect(bounds(&s, g), Rect::new(40.0, -40.0, 260.0, 60.0)), "the group is twice as wide, as tall as before: {:?}", bounds(&s, g)); + // Each child scales with the group across the page: the bar's own height (now horizontal) + // doubles and its length stays; both keep their rotation and flip. + assert!(near(size(&s, bar).0, 100.0) && near(size(&s, bar).1, 40.0) && near(rotation(&s, bar), 90.0), "{:?}", size(&s, bar)); + assert!(near_rect(bounds(&s, bar), Rect::new(40.0, -40.0, 80.0, 60.0)), "{:?}", bounds(&s, bar)); + assert!(flipped(&s, sq) && near_rect(bounds(&s, sq), Rect::new(160.0, 0.0, 260.0, 50.0)), "{:?}", bounds(&s, sq)); +} + +#[test] +fn nested_turned_groups_and_constraints_scale_along_each_layers_axes() { + let mut s = session(); + // A group turned 90° holding a 100×20 bar, inside an outer group with a square: stretching the + // outer group twice as wide makes the bar's page width (its own height) twice as large. + let bar = rect(&mut s, 0.0, 0.0, 100.0, 20.0, None); + let inner = id(&ex(&mut s, "object.group", json!({"ids": [bar]}))); + set(&mut s, &[inner], json!({"rotation": 90})); + let sq = rect(&mut s, 100.0, 0.0, 50.0, 50.0, None); + let outer = id(&ex(&mut s, "object.group", json!({"ids": [inner, sq]}))); + let (b0, o0) = (bounds(&s, bar), bounds(&s, outer)); + set(&mut s, &[outer], json!({"width": o0.width() * 2.0})); + let b1 = bounds(&s, bar); + assert!(near(b1.width(), b0.width() * 2.0) && near(b1.height(), b0.height()), "{b0:?} → {b1:?}"); + assert!(near(size(&s, bar).0, 100.0) && near(size(&s, bar).1, 40.0)); + // A frame's child turned 90° with Scale constraints: the frame twice as wide makes the child's + // page width (its own height) twice as large, not its length. + let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 200}))); + let c = rect(&mut s, 50.0, 50.0, 100.0, 20.0, Some(f)); + set(&mut s, &[c], json!({"rotation": 90, "constraints": {"horizontal": "scale", "vertical": "scale"}})); + let before = bounds(&s, c); + set(&mut s, &[f], json!({"width": 400})); + let after = bounds(&s, c); + assert!(near(after.width(), before.width() * 2.0) && near(after.height(), before.height()), "{before:?} → {after:?}"); + assert!(near(after.x0, before.x0 * 2.0) && near(rotation(&s, c), 90.0)); +} + +#[test] +fn several_layers_with_different_rotations_resize_together() { + let mut s = session(); + // P upright (0..100 × 0..50); Q 100×50 turned 90° about its centre (225..275 × -25..75): a + // page-aligned box 0..275 × -25..75. + let p = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None); + let q = rect(&mut s, 200.0, 0.0, 100.0, 50.0, None); + set(&mut s, &[q], json!({"rotation": 90})); + ex(&mut s, "select.set", json!({"ids": [p, q]})); + let b = selection_box(s.doc().unwrap(), &[p, q]).unwrap(); + assert_eq!(b[0], Point::new(0.0, -25.0)); + assert_eq!(b[2], Point::new(275.0, 75.0)); + // Twice as wide from the right edge: everything scales across the page, each layer along its + // own axes, keeping its rotation. + let steps = undo_steps(&s); + drag(&mut s, Point::new(275.0, 25.0), Point::new(550.0, 25.0), NONE); + assert!(near_rect(bounds(&s, p), Rect::new(0.0, 0.0, 200.0, 50.0)), "{:?}", bounds(&s, p)); + assert!(near_rect(bounds(&s, q), Rect::new(450.0, -25.0, 550.0, 75.0)), "{:?}", bounds(&s, q)); + assert!(near(size(&s, q).0, 100.0) && near(size(&s, q).1, 100.0) && near(rotation(&s, q), 90.0), "{:?}", size(&s, q)); + assert_eq!(undo_steps(&s), steps + 1); + ex(&mut s, "edit.undo", json!({})); + // Dragged past the opposite edge, the arrangement mirrors and each layer flips. + drag(&mut s, Point::new(275.0, 25.0), Point::new(-275.0, 25.0), NONE); + assert!(near_rect(bounds(&s, p), Rect::new(-100.0, 0.0, 0.0, 50.0)), "{:?}", bounds(&s, p)); + assert!(near_rect(bounds(&s, q), Rect::new(-275.0, -25.0, -225.0, 75.0)), "{:?}", bounds(&s, q)); + assert!(flipped(&s, p) && flipped(&s, q)); +} + +#[test] +fn design_panel_edits_reach_every_selected_layer() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None); + let b = rect(&mut s, 200.0, 0.0, 60.0, 30.0, None); + set(&mut s, &[b], json!({"rotation": 20})); + // Typing W, H, X or rotation into a mixed field sets it on each layer (one undo step). + let steps = undo_steps(&s); + set(&mut s, &[a, b], json!({"width": 80, "rotation": 45, "x": 10})); + assert_eq!(undo_steps(&s), steps + 1); + for i in [a, b] { + assert!(near(size(&s, i).0, 80.0) && near(rotation(&s, i), 45.0), "{i:?}"); + assert!(near(s.doc().unwrap().node(i).unwrap().transform.as_coeffs()[4], 10.0)); + } + // A flipped layer reads back the rotation typed and stays flipped (a horizontal flip reads + // as 180°, the form the rotation field shows). + ex(&mut s, "object.flipH", json!({"ids": [a]})); + set(&mut s, &[a], json!({"rotation": 30})); + assert!(near(rotation(&s, a), 30.0) && flipped(&s, a), "{}", rotation(&s, a)); +} + +#[test] +fn frames_resize_with_constraints_and_cmd_ignores_them() { + let mut s = session(); + let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100}))); + let c = rect(&mut s, 150.0, 10.0, 40.0, 40.0, Some(f)); + set(&mut s, &[c], json!({"constraints": {"horizontal": "right"}})); + ex(&mut s, "select.set", json!({"ids": [f]})); + drag(&mut s, Point::new(200.0, 50.0), Point::new(300.0, 50.0), NONE); + assert_eq!(bounds(&s, c).x0, 250.0, "a Right constraint keeps the distance to the right edge"); + ex(&mut s, "edit.undo", json!({})); + drag(&mut s, Point::new(200.0, 50.0), Point::new(300.0, 50.0), CMD); + assert_eq!(bounds(&s, c).x0, 150.0, "Cmd/Ctrl while resizing a frame ignores constraints"); + assert_eq!(bounds(&s, f).width(), 300.0); + // From the left edge too: the children stay where they are on the page. + drag(&mut s, Point::new(0.0, 50.0), Point::new(-50.0, 50.0), CMD); + assert_eq!((bounds(&s, f).x0, bounds(&s, c).x0), (-50.0, 150.0)); +} + +#[test] +fn lines_and_text() { + let mut s = session(); + // A line's ends set its length and angle; Shift keeps the angle to 45° steps. + let l = id(&ex(&mut s, "shape.line", json!({"x": 0, "y": 0, "width": 100}))); + ex(&mut s, "select.set", json!({"ids": [l]})); + drag(&mut s, Point::new(100.0, 0.0), Point::new(60.0, 80.0), NONE); + assert!(near(size(&s, l).0, 100.0) && near(rotation(&s, l).abs(), 53.13010235415598), "{} {}", size(&s, l).0, rotation(&s, l)); + ex(&mut s, "edit.undo", json!({})); + drag(&mut s, Point::new(100.0, 0.0), Point::new(60.0, 80.0), SHIFT); + assert!(near(rotation(&s, l).abs(), 45.0), "{}", rotation(&s, l)); + // Text: a side handle makes auto-width text fixed width (auto height); another handle a fixed + // box. + let t = id(&ex(&mut s, "text.create", json!({"text": "Hello world", "x": 0, "y": 200}))); + let auto = |s: &Session| s.doc().unwrap().node(t).unwrap().kind.text().map(|d| d.auto_resize); + // Large type, so the box is big enough for side handles whatever font the machine falls back to. + set(&mut s, &[t], json!({"fontSize": 48})); + assert_eq!(auto(&s), Some(TextAutoResize::WidthAndHeight)); + ex(&mut s, "select.set", json!({"ids": [t]})); + let b = bounds(&s, t); + // Text sizes need a font (CI may have none). + if b.height() > 4.0 { + drag(&mut s, Point::new(b.x1 + 2.0, b.center().y), Point::new(b.x1 + 30.0, b.center().y), NONE); + assert_eq!(auto(&s), Some(TextAutoResize::Height), "a side handle: auto height"); + let b = bounds(&s, t); + drag(&mut s, Point::new(b.x1 + 2.0, b.y1 + 2.0), Point::new(b.x1 + 10.0, b.y1 + 20.0), NONE); + assert_eq!(auto(&s), Some(TextAutoResize::None), "a corner: fixed size"); + } +} + +#[test] +fn nudging_and_the_pixel_grid() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None); + ex(&mut s, "select.set", json!({"ids": [r]})); + // Arrows move by 1, Shift+arrows by the big nudge (10, a preference). + ex(&mut s, "transform.nudge", json!({"dx": 1, "dy": 0})); + ex(&mut s, "transform.nudge", json!({"dx": 0, "dy": 1, "big": true})); + assert_eq!(bounds(&s, r).origin(), Point::new(1.0, 10.0)); + ex(&mut s, "pref.nudge", json!({"big": 8})); + ex(&mut s, "transform.nudge", json!({"dx": -1, "dy": 0, "big": true})); + assert_eq!(bounds(&s, r).origin(), Point::new(-7.0, 10.0)); + // With the pixel grid on, moves, resizes and new shapes land on whole pixels. + drag(&mut s, Point::new(20.0, 20.0), Point::new(30.3, 20.6), NONE); + assert_eq!(bounds(&s, r).origin(), Point::new(3.0, 11.0), "moved by a whole pixel"); + drag(&mut s, Point::new(103.0, 61.0), Point::new(150.4, 80.6), NONE); + assert_eq!(size(&s, r), (147.0, 70.0), "resized to whole pixels"); + // Off, they follow the pointer exactly. + ex(&mut s, "view.snapPixel", json!({"on": false})); + drag(&mut s, Point::new(150.0, 81.0), Point::new(160.5, 81.25), NONE); + assert_eq!(size(&s, r), (157.5, 70.25)); +} + +#[test] +fn smart_guides_snap_to_edges_centres_and_equal_spacing() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0, None); + let c = rect(&mut s, 400.0, 0.0, 100.0, 100.0, None); + let b = rect(&mut s, 200.0, 300.0, 100.0, 100.0, None); + let _ = (a, c); + ex(&mut s, "select.set", json!({"ids": [b]})); + // (drag to, B's box after, derivation): B is grabbed at its centre (250, 350). + let rows: [((f64, f64), (f64, f64), &str); 4] = [ + ((153.0, 350.0), (100.0, 300.0), "silent: an edge within 5 screen px of another layer's edge snaps to it (pinned)"), + ((302.0, 52.0), (252.0, 0.0), "edges and centres snap (its top to the others' tops)"), + ((248.0, 52.0), (200.0, 0.0), "between two layers, it snaps to equal gaps on both sides"), + ((852.0, 52.0), (800.0, 0.0), "beyond the last of a row, to the gap the row already has"), + ]; + for (to, want, why) in rows { + drag(&mut s, Point::new(250.0, 350.0), Point::new(to.0, to.1), NONE); + assert_eq!(bounds(&s, b).origin(), Point::new(want.0, want.1), "{why}"); + ex(&mut s, "edit.undo", json!({})); + } + // While dragging, the equal gaps show as labelled lines; Cmd/Ctrl turns snapping off. + s.pointer(&ev(PointerKind::Down, Point::new(250.0, 350.0), NONE)).unwrap(); + s.pointer(&ev(PointerKind::Move, Point::new(248.0, 52.0), NONE)).unwrap(); + let labels: Vec = s.tool.overlay.guides.iter().filter_map(|g| g.label.clone()).collect(); + assert_eq!(labels, vec!["100", "100"], "{:?}", s.tool.overlay.guides); + s.pointer(&ev(PointerKind::Move, Point::new(248.0, 52.0), CMD)).unwrap(); + s.pointer(&ev(PointerKind::Up, Point::new(248.0, 52.0), CMD)).unwrap(); + assert_eq!(bounds(&s, b).origin(), Point::new(198.0, 2.0)); +} + +#[test] +fn the_transform_box_and_its_handles() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0, None); + let doc = s.doc().unwrap().clone(); + let z = 1.0; + // Cursors follow the handle and the box's rotation; outside a corner, the rotation cursor. + let q = selection_box(&doc, &[r]).unwrap(); + for (h, angle) in [(Handle::Corner(0), 45.0), (Handle::Corner(1), 135.0), (Handle::Edge(0), 90.0), (Handle::Edge(1), 180.0)] { + assert!(matches!(handle_cursor(&q, h), Cursor::Resize(a) if near(a, angle)), "{h:?}"); + } + assert_eq!(handle_cursor(&q, Handle::Rotate(1)), Cursor::Rotate); + assert_eq!(handle_at(&doc, &[r], Point::new(110.0, -10.0), z), Some(Handle::Rotate(1))); + set(&mut s, &[r], json!({"rotation": 30})); + let doc = s.doc().unwrap().clone(); + let q = selection_box(&doc, &[r]).unwrap(); + let Cursor::Resize(a) = handle_cursor(&q, Handle::Edge(1)) else { panic!() }; + assert!(near((a - 150.0).rem_euclid(180.0), 0.0), "the edge cursor turns with the layer: {a}"); + // Hovering sets the cursor. + ex(&mut s, "select.set", json!({"ids": [r]})); + let corner = doc.world_transform(r) * Point::new(100.0, 100.0); + s.pointer(&ev(PointerKind::Hover, corner, NONE)).unwrap(); + assert!(matches!(s.tool.overlay.cursor, Cursor::Resize(_))); + // A layer smaller than its handles on screen: a press inside it moves it, the handles still + // answer just outside it. + let tiny = rect(&mut s, 300.0, 0.0, 4.0, 4.0, None); + ex(&mut s, "select.set", json!({"ids": [tiny]})); + let doc = s.doc().unwrap().clone(); + assert_eq!(handle_at(&doc, &[tiny], Point::new(302.0, 2.0), z), None, "inside: no handle"); + assert_eq!(handle_at(&doc, &[tiny], Point::new(297.0, 297.0 - 300.0), z), Some(Handle::Corner(0)), "outside the corner: its handle"); + drag(&mut s, Point::new(302.0, 2.0), Point::new(322.0, 12.0), NONE); + assert_eq!(bounds(&s, tiny), Rect::new(320.0, 10.0, 324.0, 14.0), "a press inside moves it"); + drag(&mut s, Point::new(326.0, 16.0), Point::new(340.0, 30.0), NONE); + assert_eq!(bounds(&s, tiny), Rect::new(320.0, 10.0, 340.0, 30.0), "the corner handle just outside resizes it"); + // At a larger zoom the same layer is big enough on screen for its handles inside it. + assert_eq!(handle_at(s.doc().unwrap(), &[tiny], Point::new(320.2, 10.2), 20.0), Some(Handle::Corner(0))); +} diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 27422e5..b99d7d8 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -99,4 +99,12 @@ fn set_rotation_keeps_center() { let c1 = r * Point::new(50.0, 25.0); assert!((c0 - c1).hypot() < 1e-9); assert!((rotation_degrees(r) - 90.0).abs() < 1e-9); + // A flipped box stays flipped and reads back the rotation set, whichever way it was flipped. + for flip in [Affine::FLIP_X, Affine::FLIP_Y] { + for deg in [0.0, 30.0, -120.0] { + let r = set_rotation_about_center(xf * flip, 100.0, 50.0, deg); + assert!(is_flipped(r) && (rotation_degrees(r) - deg).abs() < 1e-9, "{deg}"); + assert!((xf * flip * Point::new(50.0, 25.0) - r * Point::new(50.0, 25.0)).hypot() < 1e-9); + } + } } diff --git a/crates/geom/src/transform.rs b/crates/geom/src/transform.rs index 62eb624..f992060 100644 --- a/crates/geom/src/transform.rs +++ b/crates/geom/src/transform.rs @@ -34,11 +34,12 @@ pub fn from_position_rotation(x: f64, y: f64, degrees: f64) -> Affine { } /// Change the rotation of `xf` to `degrees` about the centre of a `w`×`h` box in its local space, -/// keeping that centre in place and keeping any flip. +/// keeping that centre in place and keeping any flip. A flip is kept as a vertical one, the form +/// [`rotation_degrees`] reads (a horizontal flip reads as 180°), so the rotation set reads back. pub fn set_rotation_about_center(xf: Affine, w: f64, h: f64, degrees: f64) -> Affine { let center_local = Point::new(w / 2.0, h / 2.0); let center = xf * center_local; - let flip = if is_flipped(xf) { Affine::FLIP_X } else { Affine::IDENTITY }; + let flip = if is_flipped(xf) { Affine::FLIP_Y } else { Affine::IDENTITY }; let rot = Affine::rotate(-degrees.to_radians()); let lin = rot * flip; // Solve translation so that lin * center_local + t = center. diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 4d99b47..cdbf2d2 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -73,6 +73,19 @@ fn linear(a: Affine) -> Affine { Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0]) } +/// How much a layer's own width and height scale (no skew: it keeps its rotation and flip) when +/// its parent's space scales by `kx`, `ky`: how much each of its axes (`xf`'s linear part) +/// stretches. A layer turned 90° swaps the two; one turned 45° gets a mix. +pub fn axis_factors(xf: Affine, kx: f64, ky: f64) -> (f64, f64) { + let [a, b, c, d, _, _] = xf.as_coeffs(); + let along = |x: f64, y: f64, fallback: f64| { + let len = x.hypot(y); + let k = (kx * x).hypot(ky * y) / len; + if len > 1e-12 && k.is_finite() { k } else { fallback } + }; + (along(a, b, kx), along(c, d, ky)) +} + /// Place `id` so its (rotated) bounding box's top-left is at `(x, y)` in the parent. fn place(doc: &mut Document, id: NodeId, x: f64, y: f64) { let Some(n) = doc.node(id) else { return }; @@ -912,9 +925,9 @@ pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) { y1 *= sy; } } - // New local size from the bbox change (axis-aligned children exactly; rotated approximately). - let kw = if bw > 1e-9 { (x1 - x0) / bw } else { 1.0 }; - let kh = if bh > 1e-9 { (y1 - y0) / bh } else { 1.0 }; + // New local size from the bbox change, along the child's own axes (axis-aligned children, + // turned or flipped, exactly; others approximately, never skewed). + let (kw, kh) = axis_factors(lin, if bw > 1e-9 { (x1 - x0) / bw } else { 1.0 }, if bh > 1e-9 { (y1 - y0) / bh } else { 1.0 }); let (new_w, new_h) = (cw * kw, ch * kh); let resized = (new_w - cw).abs() > 1e-9 || (new_h - ch).abs() > 1e-9; if resized { @@ -935,16 +948,20 @@ pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) { } /// Scale a group's children when the group is resized (groups scale their contents). +/// Each child keeps its rotation and flip and scales along its own axes ([`axis_factors`]), its +/// centre following the group's scale. pub fn scale_group_children(doc: &mut Document, id: NodeId, kx: f64, ky: f64) { let Some(n) = doc.node(id) else { return }; for c in n.children.clone() { let Some(cn) = doc.node(c) else { continue }; - let b = figcraft_geom::transform_rect_bbox(cn.transform, cn.local_rect()); + let center = figcraft_geom::transform_rect_bbox(cn.transform, cn.local_rect()).center(); let (w, h) = (cn.width, cn.height); - set_size(doc, c, w * kx, h * ky); - place(doc, c, b.x0 * kx, b.y0 * ky); + let (kw, kh) = axis_factors(cn.transform, kx, ky); + set_size(doc, c, w * kw, h * kh); + let (nw, nh) = outer_size(doc, c); + place(doc, c, center.x * kx - nw / 2.0, center.y * ky - nh / 2.0); if doc.node(c).is_some_and(|n| n.kind.bounds_follow_children()) { - scale_group_children(doc, c, kx, ky); + scale_group_children(doc, c, kw, kh); } else if doc.node(c).is_some_and(|n| n.kind.is_frame_like()) { apply_constraints(doc, c, w, h); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e51e5b6..7dde290 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -860,7 +860,20 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if let Some(hint) = scale { label = format!("{label} · {}x", fmt(hint.factor.abs())); } - size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, if is_line { 2 } else { 4 }, label); + // A layer smaller than its handles on screen shows just its outline (they still answer + // just outside it). + let tiny = figcraft_engine::tools::box_sides_px(&b, zoom).1 < figcraft_engine::tools::SMALL_BOX_PX; + // Locked layers (selected in the layers panel) can't be resized: no handles either. + let handles = if sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) { + 0 + } else if is_line { + 2 + } else if tiny { + 0 + } else { + 4 + }; + size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, handles, label); arc_overlay(app, painter, col); if app.session.tool.tool == ToolId::Scale { // The point that stays put: the dragged pivot, else the anchor. diff --git a/docs/mcp.md b/docs/mcp.md index 67137e8..3b7f517 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -982,7 +982,18 @@ guide, along the frame's axis nearer the ruler's. depth (the context menu's Select layer); `select.set`, `select.children` / `select.parent` (Enter / Shift+Enter), `select.next` / `select.previous` (Tab / Shift+Tab). With `pointer`, `cmd: true` clicks select the nested layer, `cmd` drags from empty canvas select the nested layers they touch, - and `shift` drags toggle what they touch. + and `shift` drags toggle what they touch. A press on the background of a top-level frame with + layers in it (the frame not selected) starts a marquee inside it, as on the canvas; an empty frame + is picked and dragged. Locked layers (selectable with `select.set`) get no handles and stay put + when the rest of the selection is dragged. `select.all` / `select.inverse` skip hidden and locked + layers. +- **Transform gestures**: with `pointer`, `shift` keeps proportions, `alt` resizes from the centre + (both together too), dragging past the opposite edge flips (several layers mirror and flip); + groups and several layers scale each layer along its own axes, keeping its rotation and flip; + `cmd` while resizing a frame ignores its children's constraints (they stay where they are) and, + as in every drag, turns snapping off. Moves snap to edges, centres and equal spacing (the gaps + show in `tool.overlay.guides` with labels); with the pixel grid on, moves and resizes land on + whole pixels. Tool failures come back as results with `isError: true` and a message; protocol problems (unknown tool, malformed JSON, wrong parameter types) are JSON-RPC errors. Nothing crashes the server. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 10b0016..3f3de39 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -95,7 +95,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | # | Area | Weight | Breadth | Ready | Remaining | Hours | |---|---|---|---|---|---|---| | 1 | Canvas & navigation | 6 | 97% | 72% | Display P3 on screen, QA at extreme zooms (outlines for the selection and mask outlines landed) | 2–5 | -| 2 | Selection & transform | 8 | 95% | 68% | Handles, measuring and snapping checked tool by tool against Figma; rotated groups and mixed selections | 6–10 | +| 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 | 95% | 78% | Keyboard box selection, keyboard rename/context menu, long-list QA | 2–4 | | 4 | Shapes & vector | 8 | 97% | 68% | Vector QA with real files (live boolean editing, boolean hit testing on the result, arc fields, shape builder on strokes and open paths and one-sided widths landed) | 1–2 | | 5 | Paint | 7 | 87% | 66% | Video fills, wide-gamut canvas, embedded image profiles, HEIC images (animated GIF fills and TIFF images landed) | 4–7 | @@ -122,7 +122,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Dimension | Weight | Ready | Hours | Evidence | Doc | |---|---|---|---|---|---| -| Features (depth) | 35 | 57% | 60–115 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file | +| Features (depth) | 35 | 57% | 60–115 (areas 1–13, 15, 16, 20) | The table above; core editor ~68% | this file | | UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | | File formats | 12 | 55% | 8–15 | `.fig` read, best effort (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) | | Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | @@ -580,6 +580,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h | | 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Import/export breadth 65% → 80%, ready 35% → 50%, 30–60 h → 8–15 h; file formats 40% → 55%, 30–60 h → 8–15 h; feature breadth ~79% → ~80% (core ~90% → ~91%), areas ready ~57% → ~58% (core ~67% → ~68%); ready for real work ~47% → ~49%; essentials ~53% → ~57% (opening files people send ×0.80 → ×0.88); mainstream ~43% → ~46% (weekly areas 66.8% → 67.8%, file exchange ×0.80 → ×0.85); beta ~195–355 h → ~173–310 h; full parity ~345–666 h → ~323–621 h | | 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h | | 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f6e77e7..98f51b7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -30,21 +30,23 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| -| Click, Shift-click, marquee, Shift-marquee toggle | done | | +| Click, Shift-click, marquee, Shift-marquee toggle | done | a marquee selects what it touches; from a top-level frame's background, the layers inside it; locked and hidden layers are skipped (`selection_behaviour_tests`) | | Deep select (Cmd/Ctrl-click, Cmd/Ctrl-marquee) | done | | | Select layer menu (every layer under the click) | done | | | Enter / Shift+Enter (children / parent), Tab / Shift+Tab (siblings) | done | Tab renames the next layer while renaming | -| Resize handles: Shift keeps proportions, Alt from centre, constraints-aware | done | | +| Resize handles: Shift keeps proportions, Alt from centre, constraints-aware | done | Alt shrinks too; past an edge flips (several layers mirror); groups and mixed rotations scale each layer along its own axes, keeping rotation and flip (no skew: children turned off a right angle are approximate); Cmd/Ctrl ignores a frame's constraints (`transform_behaviour_tests`) | | Rotate just outside a corner; Shift in 15° steps | done | rotated handle box when layers share a rotation | | Flip, rotate 90° | done | | | Alt-drag duplicate | done | | | Scale tool (K): handles about an anchor, live factor, panel | done | | | Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children | -| Smart guides: edges, centres, spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; thresholds not yet compared with Figma's | +| Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; thresholds not yet compared with Figma's | | Ctrl to suspend snapping while dragging (macOS) | missing | | | Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap | -| Pixel grid snapping, pixel preview, 2x preview | done | | +| Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels | | Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | partial | check scrub speed and Shift/Alt steps | +| Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | +| Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Keyboard box selection | missing | | ## Tools @@ -96,6 +98,7 @@ interaction feel not yet compared. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Selection and transform behaviour suite and fixes: Alt shrinks, groups and mixed rotations scale each layer along its own axes, several layers flip together, whole-pixel resizes, small layers move from inside, locked layers stay put, marquees from a top-level frame's background, equal spacing snapping, Cmd/Ctrl ignores constraints while resizing a frame | | 2026-10-11 | minor | Gamut warnings: the colour picker flags Display P3 colours outside sRGB; View ▸ Soft-proof sRGB stripes them on the canvas | | 2026-10-11 | minor | Several windows: File ▸ New window, Open in new window on a tab, a tab dragged off the strip; each window has its own tabs and panels and asks about unsaved changes when it closes | | 2026-10-11 | minor | Canvas fixes: boolean groups clicked where their result is, a layer dragged inside a group stays in it, View ▸ Frame outlines and View ▸ Slices draw | From f665765128db93ddd18ad9f5d93b33172aaae30e Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 11 Oct 2026 22:09:46 +0700 Subject: [PATCH 2/3] M4: text behaviour suite: metrics, line height, CJK (#134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What **A text behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (Auto / % / px line height and how the leading is split, letter spacing in % and px, paragraph spacing and indent, auto width / auto height / fixed / truncate with max lines, vertical trim, decorations from the font's metrics) is a table-driven case with the numbers the rule implies and a one-line derivation, read from the face actually used so it holds on any machine with a font. CJK line breaking follows UAX #14 and the CJK line-start / line-end rules; caret steps follow UAX #29 grapheme clusters. Where the documentation is silent the case says so and pins today's behaviour. **Fixes (6 of the 13 text cases fail on the old layout; the break and caret rules are new functions)** - **CJK line breaking**: lines break between ideographs, kana and Hangul and before or after a Latin word next to them (before: no break before an ideograph that follows Latin, none after `。`, full-width forms, CJK punctuation or Extension B ideographs), never inside a Latin word, with kinsoku: closing brackets and quotes, `、。,.!?`, small kana, the prolonged sound mark and iteration marks never start a line (the character before moves down with them), opening brackets never end one. The ideographic space hangs at the line's end like a space. One small break-class table in `crates/text/src/breaks.rs`, no new crate. - **Fallback fonts no longer move the baseline**: a style's ascent, descent, line height and cap height come from its own font, not the fallback face that drew some of its characters, so a line with CJK or emoji keeps the height and baseline of a Latin line (with a fixed line height the mixed line's baseline used to shift by the CJK face's taller metrics; with Auto the line grew). Decorations likewise use the style's font and size, so an underline runs at one height and weight through mixed scripts (and isn't thinned by synthesised small caps). - **Paragraph indent applies to left-aligned text only** (documented); right-to-left paragraphs keep it when right-aligned (their start). The Type settings field is disabled with a hint otherwise. - **Carets by grapheme cluster**: Left / Right, Delete and Backspace step over a whole emoji ZWJ sequence, flag, skin-tone sequence or letter with combining marks (they moved one code point at a time, leaving the caret inside an emoji and deleting half of it). A cluster is also shaped with one font, so a joiner or variation selector no longer splits an emoji sequence across faces. - **Words by script**: double-click and word moves (Alt on macOS, Ctrl on Windows and Linux) take one script's run in text without spaces (`日本語` | `の` | `テキスト`), Hangul words end at spaces, and a Latin word keeps its combining marks (before: a whole CJK sentence was one word, and `cafe` + combining acute split at the accent). **Missing documented setting, added** - **Vertical trim** (cap height to baseline): `leadingTrim: none | capHeight` (`CAP_HEIGHT` from REST JSON) through `node.set`, a Standard / cap-height segmented control in Type settings, `inspect_document` and Dev Mode properties, CSS `text-box: trim-both cap alphabetic` in generated code. The box runs from the first line's cap height to the last baseline (auto height shrinks, auto layout parents follow); truncation counts the trimmed bottom; exports reach past the box so descenders below the last baseline aren't clipped. Canvas, PNG, SVG and PDF all draw from the same layout. New strings go through `tr(...)` with `en.toml` / `es.toml` entries; the new segmented control reuses the Type settings one (accessible toggle names). Each edit is one undo step. `unicode-segmentation` (already in `Cargo.lock` through egui and winit) became a direct dependency of `figcraft-text`. ## Verified - **Desktop app** over the control channel (own target dir, separate `APPDATA`, stopped by PID): a 200 px auto-height box of Japanese wraps with no line starting with `、。」` and `「` never ending a line (`…テスト` / `を折り返す、テスト` / `です。「括弧」は行` / `末に来ません。漢` / `字とかなとカタカ` / `ナ。`, the last `ナ` carried down with `。`); a Latin / CJK / Latin paragraph at 36 px line height is 108 px with evenly spaced baselines; the same text with vertical trim is 89.45 px (cap height 17.45 + 2 × 36), its box running from the cap height to the last baseline, and the Type settings control turns it off (108 px) and undo brings it back in one step; one Right arrow from after `a` in `a👨‍👩‍👧b` lands after the whole family emoji and Shift+Right then selects `b`; a double click selects the katakana run テキスト. - **MCP**: `figcraft-cli mcp` (headless) built the same three texts; `inspect_document` reports 108 px for the 36 px paragraph and 89.42 px with `leadingTrim: capHeight` for the trimmed one (cap height of the headless font), the PNG export wraps the Japanese by the same rules and keeps the trimmed text's descenders, and `inspect.code` emits `text-box: trim-both cap alphabetic`. - **Web**: `cargo xtask wasm` passes (part of the gate). - **Performance**: text layout of a 2,250-char Latin and a 2,000-char Japanese paragraph wrapped at 300 px (release, median of 7 × 30 runs) before and after on the same machine: Latin 0.70 → 0.42–0.50 ms, Japanese 0.46 → 0.37–0.47 ms (within noise or faster: line metrics are now computed once per style change instead of per glyph). All-ASCII text skips grapheme segmentation. - `cargo +1.99.0 xtask ci` passes (fmt, clippy, tests, layers, assets, wasm), after merging #131 and #132. ## Tests - `text` (`behaviour_tests`, 13 tests): line height Auto / % / px; leading split above and below every line (positive and negative); letter spacing % / px / negative; paragraph spacing and indent by alignment; resizing and truncation with max lines; vertical trim (heights, first baseline, glyph spacing kept, truncation); decorations from the font's metrics through mixed scripts; fallback fonts keep line height and baseline (Auto, px, %); 22 break-opportunity cases; CJK wrapping with kinsoku, Latin words in CJK and Korean; the ideographic space hanging; grapheme steps over ZWJ families, flags, combining marks, skin tones and emoji presentation; one face per cluster. Font-dependent cases skip on a machine without fonts. - `engine` (`text_behaviour_tests`, 5): arrows, Shift+arrows, Delete and Backspace by cluster; double-click and word moves by script (and through `keymap::text_motion` for Ctrl and Alt); IME composition with CJK wrapping and growing the box before commit; vertical trim through `node.set` (box size, PNG ink at the top, export bounds below the box, SVG and PDF, CSS, inspect, undo, junk refused, REST spelling); paragraph indent only on left-aligned text. - `format` REST import test covers `leadingTrim: CAP_HEIGHT`. ## Not in this PR - Documented but not modelled yet: list spacing, hanging quotes and hanging lists, underline style / thickness / offset / skip ink. - Documentation silent, behaviour pinned: Korean wraps by syllable (no word mode); Han compounds next to each other are one word (no dictionary segmentation); letter spacing is added after the last character too; a fallback font's taller glyphs may overflow the line rather than grow it. - `.fig` import doesn't read vertical trim yet (the field wasn't checked against real files). - Spell check (on hold, #113). - Progress docs: target-app-parity area 10 85%/64% → 87%/68%, 7–13 h → 5–10 h with the suite listed under text editing depth; feature breadth and ready for real work unchanged at this rounding; full parity ~323–621 h → ~321–618 h (target-app-parity, ROADMAP); ui-parity text row. --- Cargo.lock | 1 + Cargo.toml | 1 + ROADMAP.md | 7 +- crates/doc/src/node.rs | 2 +- crates/doc/src/text.rs | 29 +- crates/engine/src/cmd/props.rs | 3 +- crates/engine/src/codegen.rs | 3 + crates/engine/src/devinspect.rs | 3 + crates/engine/src/inspect.rs | 3 + crates/engine/src/lib.rs | 2 + crates/engine/src/text_behaviour_tests.rs | 194 ++++++++++++ crates/engine/src/textedit.rs | 79 +++-- crates/format/src/rest/tests.rs | 3 +- crates/format/src/rest/text.rs | 3 + crates/text/Cargo.toml | 1 + crates/text/src/behaviour_tests.rs | 342 ++++++++++++++++++++++ crates/text/src/breaks.rs | 100 +++++++ crates/text/src/layout.rs | 111 ++++--- crates/text/src/lib.rs | 4 + crates/ui-egui/locales/en.toml | 4 + crates/ui-egui/locales/es.toml | 4 + crates/ui-egui/src/typography.rs | 24 +- docs/target-app-parity.md | 25 +- docs/ui-parity.md | 2 +- 24 files changed, 857 insertions(+), 93 deletions(-) create mode 100644 crates/engine/src/text_behaviour_tests.rs create mode 100644 crates/text/src/behaviour_tests.rs create mode 100644 crates/text/src/breaks.rs diff --git a/Cargo.lock b/Cargo.lock index 5e2cfdc..05cdf10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1670,6 +1670,7 @@ dependencies = [ "log", "skrifa 0.47.0", "unicode-bidi", + "unicode-segmentation", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ba84d06..7db51f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ krilla = "0.8" skrifa = "0.47" harfrust = "0.13" unicode-bidi = "0.3" +unicode-segmentation = "1.13" linesweeper = "0.4" zip = { version = "2", default-features = false, features = ["deflate"] } # `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe). diff --git a/ROADMAP.md b/ROADMAP.md index b2ce7d3..09b5522 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~173–310 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -19,7 +19,7 @@ start with [`docs/gaps.md`](docs/gaps.md). | Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | | Mainstream practitioner | **~46%** | estimated: weekly areas 67.8% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~173–310 h** one agent | estimated | -| Remaining to full parity | **~323–621 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~321–618 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | ### Readiness by audience @@ -81,7 +81,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) | | Frames, groups, sections, masks | 95% | 75% | 2–4 | [target-app-parity](docs/target-app-parity.md) | | Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) | -| Text | 85% | 64% | 7–13 | [target-app-parity](docs/target-app-parity.md) | +| Text | 87% | 68% | 5–10 | [target-app-parity](docs/target-app-parity.md) | | Components & variants | 95% | 68% | 6–12 | [components-parity](docs/components-parity.md) | | Styles & variables | 92% | 72% | 1–2 | [components-parity](docs/components-parity.md) | | Prototyping | 88% | 70% | 5–9 | [prototyping-parity](docs/prototyping-parity.md) | @@ -195,6 +195,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h | | 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h | | 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 in the desktop app and through MCP; eight fixes: recovery copies of unsaved documents reopen after a crash or a forced quit (`file.recover`), Ctrl moves and deletes by word in text on Windows and Linux, scrubbed fields keep their value, typing on a field Tab reached edits it, Escape closes dialogs, linear gradients keep square bands, panel hints wrap, the window-close question and "Open in new window" are translated. Percentages and hours unchanged | | 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h | diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index fa30741..8ca35b6 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -705,7 +705,7 @@ impl Node { 0.0 }; let fx = self.effects.iter().map(Effect::reach).fold(0.0, f64::max); - let glyphs = if let NodeKind::Text(t) = &self.kind { t.path_reach() } else { 0.0 }; + let glyphs = if let NodeKind::Text(t) = &self.kind { t.path_reach() + t.trim_reach() } else { 0.0 }; stroke + fx + self.spread_miter_reach() + glyphs } diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 291f211..e10fc8a 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -158,6 +158,17 @@ pub enum TextAutoResize { Truncate, } +/// Vertical trim: what bounds a text box vertically. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum LeadingTrim { + /// The full line height of the first and last lines. + #[default] + None, + /// From the first line's cap height to the last line's baseline. + CapHeight, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub enum ListStyle { @@ -318,6 +329,10 @@ pub struct TextData { pub paragraph_indent: f64, #[serde(skip_serializing_if = "crate::node::is_default")] pub list: ListStyle, + /// Vertical trim (`capHeight`: the box runs from the first line's cap height to the last + /// line's baseline). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub leading_trim: LeadingTrim, /// Max lines when truncating (0 = as many as fit). #[serde(skip_serializing_if = "crate::node::is_default")] pub max_lines: u32, @@ -391,9 +406,19 @@ impl TextData { if self.path.is_none() { return 0.0; } - let size = self.spans().iter().map(|(_, _, s)| s.font_size).filter(|v| v.is_finite()).fold(0.0, f64::max).min(5000.0); let lines = self.characters.lines().count().clamp(1, 10_000) as f64; - size * 1.5 * lines + self.max_size() * 1.5 * lines + } + + /// How far vertically trimmed text paints past its box (descenders below the last baseline, + /// accents above the first cap height): half the largest size; 0 untrimmed. + pub fn trim_reach(&self) -> f64 { + if self.leading_trim == LeadingTrim::None { 0.0 } else { self.max_size() * 0.5 } + } + + /// The largest font size used (capped like layout caps it). + fn max_size(&self) -> f64 { + self.spans().iter().map(|(_, _, s)| s.font_size).filter(|v| v.is_finite()).fold(0.0, f64::max).min(5000.0) } pub fn char_count(&self) -> usize { diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 137daa6..1b18282 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -19,7 +19,7 @@ gridColumn / gridRow (0-based cell anchor, null = auto), gridColumnSpan, gridRow absolute (ignore auto layout; follows constraints), minWidth, maxWidth, minHeight, maxHeight (null removes), constraints {horizontal, vertical}, grids, exports, characters, fontFamily, fontStyle, \ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (left|center|right|justified), textAlignVertical, textAutoResize \ (widthAndHeight|height|none|truncate), textDecoration, textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \ -listStyle (none|bulleted|numbered), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \ +listStyle (none|bulleted|numbered), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \ fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), pointCount, innerRadius, arcStart, arcSweep, \ arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \ group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}"; @@ -581,6 +581,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map text_mut(cmd, doc.get_mut(id)?)?.paragraph_spacing = f64_of(v).unwrap_or(0.0).max(0.0), "paragraphIndent" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).unwrap_or(0.0).max(0.0), "listStyle" => text_mut(cmd, doc.get_mut(id)?)?.list = enum_of(v).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?, + "leadingTrim" => text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?, "maxLines" => text_mut(cmd, doc.get_mut(id)?)?.max_lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32, "features" | "textFeatures" | "fontFeatures" | "openTypeFeatures" => { let f = features_of(cmd, v)?; diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index cfb11f2..46d5d57 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -370,6 +370,9 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo LetterSpacing::Percent(p) if p != 0.0 => put("letter-spacing", format!("{}em", num(p / 100.0))), _ => {} } + if tx.leading_trim == figcraft_doc::LeadingTrim::CapHeight { + put("text-box", "trim-both cap alphabetic".into()); + } } out } diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index 8513ac8..e79262e 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -444,6 +444,9 @@ fn typography(doc: &Document, n: &Node, t: &TextData) -> Section { if t.paragraph_spacing > 0.0 { rows.push(prop("Paragraph spacing", num(t.paragraph_spacing))); } + if t.leading_trim == figcraft_doc::LeadingTrim::CapHeight { + rows.push(prop("Vertical trim", "cap height to baseline")); + } let align = match t.align_h { TextAlignH::Left => "left", TextAlignH::Center => "center", diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index b1c0270..fdc06f1 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -175,6 +175,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { if t.style.decoration != TextDecoration::None { o.insert("textDecoration".into(), json!(t.style.decoration)); } + if t.leading_trim != figcraft_doc::LeadingTrim::None { + o.insert("leadingTrim".into(), json!(t.leading_trim)); + } if !t.runs.is_empty() { o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null)); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 38caf70..5a0d1c5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1325,6 +1325,8 @@ mod svg_import_tests; #[cfg(test)] mod tests; #[cfg(test)] +mod text_behaviour_tests; +#[cfg(test)] mod text_fill_tests; #[cfg(test)] mod text_motion_tests; diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs new file mode 100644 index 0000000..51550f7 --- /dev/null +++ b/crates/engine/src/text_behaviour_tests.rs @@ -0,0 +1,194 @@ +//! Text behaviour through commands (the rules of `figcraft_text`'s behaviour suite that involve +//! editing or settings): caret moves, deletes and word selection by grapheme cluster and by script, +//! vertical trim and paragraph indent through `node.set`, and renders and exports from one layout. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::textedit::{begin, key, select_word}; +use figcraft_doc::{LeadingTrim, NodeId, NodeKind}; + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn text(s: &mut Session, chars: &str, props: Value) -> NodeId { + id(s.execute("text.create", &json!({"text": chars, "x": 0, "y": 0, "props": props})).unwrap()) +} + +fn chars(s: &Session, t: NodeId) -> String { + s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters.clone() +} + +fn caret(s: &Session) -> (usize, usize) { + let te = s.ds().unwrap().text_edit.clone().unwrap(); + (te.anchor, te.caret) +} + +fn size(s: &Session, t: NodeId) -> (f64, f64) { + let n = s.doc().unwrap().node(t).unwrap(); + (n.width, n.height) +} + +const FAMILY: &str = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"; + +#[test] +fn arrows_and_deletes_step_over_whole_clusters() { + // A user-perceived character (emoji ZWJ sequence, flag, letter + combining mark) is one step. + let mut s = Session::new(); + let src = format!("a{FAMILY}b\u{1F1EF}\u{1F1F5}e\u{0301}"); + let t = text(&mut s, &src, json!({"fontSize": 24})); + begin(&mut s, t, Some(0), false).unwrap(); + // (key, caret after, derivation) + for (k, want, why) in [ + ("Right", 1, "over 'a'"), + ("Right", 6, "over the five-char family emoji"), + ("Right", 7, "over 'b'"), + ("Right", 9, "over the two regional indicators of a flag"), + ("Right", 11, "over e + combining acute"), + ("Right", 11, "the end stays"), + ("Left", 9, "back over é"), + ("Left", 7, "back over the flag"), + ("Left", 6, "back over 'b'"), + ("Left", 1, "back over the family"), + ] { + key(&mut s, k, false, false, false).unwrap(); + assert_eq!(caret(&s), (want, want), "{why}"); + } + // Shift extends by clusters too. + key(&mut s, "Right", true, false, false).unwrap(); + assert_eq!(caret(&s), (1, 6)); + key(&mut s, "Left", false, false, false).unwrap(); + // Delete and Backspace remove a whole cluster. + key(&mut s, "Delete", false, false, false).unwrap(); + assert_eq!(chars(&s, t), "ab\u{1F1EF}\u{1F1F5}e\u{0301}"); + key(&mut s, "Right", false, false, true).unwrap(); // to the end + key(&mut s, "Backspace", false, false, false).unwrap(); + assert_eq!(chars(&s, t), "ab\u{1F1EF}\u{1F1F5}"); + key(&mut s, "Backspace", false, false, false).unwrap(); + assert_eq!(chars(&s, t), "ab"); +} + +#[test] +fn word_selection_and_motion_follow_scripts() { + // CJK text has no spaces: a double click or word move takes the run of one script (Han, + // hiragana, katakana, Hangul); Latin words keep their combining marks. Documentation silent on + // dictionary segmentation (a Han compound next to a Han word is one run). + let mut s = Session::new(); + let src = "日本語のテキストです。한국어 단어 cafe\u{0301} ok"; + let t = text(&mut s, src, json!({"fontSize": 24})); + begin(&mut s, t, Some(0), false).unwrap(); + // (double-click index, selection, derivation) + for (at, want, why) in [ + (1, (0, 3), "日本語: the Han run"), + (3, (3, 4), "の: one hiragana"), + (5, (4, 8), "テキスト: the katakana run"), + (9, (8, 10), "です: hiragana"), + (10, (8, 10), "right after です (before 。) selects です"), + (12, (11, 14), "한국어: the Hangul word"), + (17, (15, 17), "단어: up to the space"), + (20, (18, 23), "café with a combining accent is one word"), + ] { + select_word(&mut s, at).unwrap(); + assert_eq!(caret(&s), want, "{why}"); + } + // Word moves stop at the same edges. + crate::textedit::set_caret(&mut s, 0, false).unwrap(); + for want in [3, 4, 8, 10, 14, 17, 23, 26] { + key(&mut s, "Right", false, true, false).unwrap(); + assert_eq!(caret(&s).1, want, "word right"); + } + for want in [24, 18, 15, 11, 8, 4, 3, 0] { + key(&mut s, "Left", false, true, false).unwrap(); + assert_eq!(caret(&s).1, want, "word left"); + } + // The platforms' word keys reach the same moves: Ctrl+arrow on Windows and Linux, Alt on macOS. + use crate::keymap::{Mods, Platform, text_motion}; + for (platform, m) in [(Platform::Other, Mods { ctrl: true, ..Default::default() }), (Platform::Mac, Mods { alt: true, ..Default::default() })] { + crate::textedit::set_caret(&mut s, 0, false).unwrap(); + let (word, line) = text_motion(platform, "ArrowRight", m); + key(&mut s, "ArrowRight", false, word, line).unwrap(); + assert_eq!(caret(&s).1, 3, "{platform:?}: one word right"); + } +} + +#[test] +fn ime_composition_with_cjk() { + // Composing shows inline and wraps like committed text; committing types it in one go. + let mut s = Session::new(); + let t = text(&mut s, "x", json!({"fontSize": 24})); + s.execute("node.set", &json!({"id": t, "props": {"textAutoResize": "height", "width": 60, "characters": ""}})).unwrap(); + begin(&mut s, t, Some(0), false).unwrap(); + crate::textedit::preedit(&mut s, "にほんごのぶんしょう", None).unwrap(); + let (doc, range, cursor) = crate::textedit::preedit_doc(&s).unwrap(); + assert_eq!((range, cursor), ((0, 10), 10)); + assert_eq!(chars(&s, t), "", "the document waits for the commit"); + let shown = doc.node(t).unwrap(); + if let Some((l, _)) = figcraft_text::layout_node(shown).filter(|(l, _)| !l.glyphs.is_empty()) { + assert!(l.lines.len() > 1, "a long composition wraps in the 60 px box"); + assert!((shown.height - l.height).abs() < 1e-6, "the box grows with it"); + } + crate::textedit::insert(&mut s, "日本語の文章").unwrap(); + assert_eq!(chars(&s, t), "日本語の文章"); + assert!(s.ds().unwrap().text_edit.as_ref().unwrap().preedit.is_none()); +} + +#[test] +fn vertical_trim_through_node_set() { + let mut s = Session::new(); + let t = text(&mut s, "Hello\nworld", json!({"fontSize": 40, "lineHeight": 60})); + let Some((l, _)) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()) else { return }; + if l.glyphs.is_empty() { + return; + } + let (w, h) = size(&s, t); + assert!((h - 120.0).abs() < 1e-6, "two 60 px lines"); + s.execute("node.set", &json!({"id": t, "props": {"leadingTrim": "capHeight"}})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!(n.kind.text().unwrap().leading_trim, LeadingTrim::CapHeight); + let (l, _) = figcraft_text::layout_node(n).unwrap(); + let cap = l.lines[0].baseline; + // The auto-size box now runs from the first cap height to the last baseline: cap + 60. + assert!((n.height - (cap + 60.0)).abs() < 1e-6 && cap > 10.0 && cap < 40.0, "{} vs {cap}", n.height); + assert_eq!(n.width, w, "the width doesn't change"); + // Descenders hang below the trimmed box: exports reach past it to keep them. + let eb = figcraft_render::export_bounds(s.doc().unwrap(), t).unwrap(); + assert!(eb.y1 > n.height + 10.0, "{eb:?}"); + // The PNG's ink starts at the box's top (cap height at y = 0; ascenders like "l" may poke a few + // px above it, by how much depends on the font); SVG and PDF use the same layout. + let pix = figcraft_render::render_node(s.doc().unwrap(), t, 1.0).unwrap(); + let first_ink = (0..pix.height()).find(|y| (0..pix.width()).any(|x| pix.pixel(x, *y).is_some_and(|p| p.alpha() > 64))); + assert!(first_ink.is_some_and(|y| (-8.0..=2.0).contains(&(f64::from(y) + eb.y0))), "{first_ink:?} {eb:?}"); + let svg = figcraft_svg::export_svg(s.doc().unwrap(), &[t], &figcraft_svg::SvgOptions::default()).unwrap(); + assert!(!svg.contains("NaN")); + assert!(figcraft_pdf::export_nodes_pdf(s.doc().unwrap(), &[t]).is_ok()); + // Inspect and code say so; one undo step back; junk is refused. + let css = crate::codegen::css_decls(s.doc().unwrap(), s.doc().unwrap().node(t).unwrap(), &mut Default::default(), (true, true)); + assert!(css.contains("text-box: trim-both cap alphabetic"), "{css}"); + s.undo().unwrap(); + assert_eq!(size(&s, t), (w, h)); + assert!(s.execute("node.set", &json!({"id": t, "props": {"leadingTrim": "sideways"}})).is_err()); + // The REST spelling. + s.execute("node.set", &json!({"id": t, "props": {"leadingTrim": "CAP_HEIGHT"}})).unwrap(); + assert!(matches!(&s.doc().unwrap().node(t).unwrap().kind, NodeKind::Text(td) if td.leading_trim == LeadingTrim::CapHeight)); + assert_eq!(crate::inspect::node_json(s.doc().unwrap(), t, 0)["leadingTrim"], "capHeight", "inspect reports it"); +} + +#[test] +fn paragraph_indent_only_moves_left_aligned_text() { + let mut s = Session::new(); + let t = text(&mut s, "Indented paragraph", json!({"fontSize": 20, "paragraphIndent": 30})); + let first_x = |s: &Session| { + let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap(); + (l.glyphs.first().map_or(0.0, |g| g.x), l.width) + }; + let (x, w) = first_x(&s); + if w <= 0.0 { + return; + } + assert!((x - 30.0).abs() < 1e-6, "left: indented"); + s.execute("node.set", &json!({"id": t, "props": {"textAlign": "center"}})).unwrap(); + let (x, w2) = first_x(&s); + assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.abs() < 1e-6, "centered auto-width text: no indent, the box shrinks by it"); +} diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 7313035..beeb04e 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -99,26 +99,59 @@ pub fn insert(s: &mut Session, text: &str) -> Result<()> { }) } -fn is_word(c: char) -> bool { - c.is_alphanumeric() || c == '_' -} - -fn word_left(chars: &[char], mut i: usize) -> usize { - while i > 0 && chars.get(i - 1).is_some_and(|c| !is_word(*c)) { +/// The kind of word char `i` belongs to: 0 for spaces and punctuation, else its script, so a word +/// ends where the script changes (Han, hiragana, katakana and Hangul runs are words of their own in +/// text without spaces). Combining marks and voicing marks belong to the char before them. +fn word_class(chars: &[char], mut i: usize) -> u8 { + while i > 0 + && chars.get(i).is_some_and( + |c| matches!(*c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0x3099..=0x309A | 0xFE20..=0xFE2F), + ) + { i -= 1; } - while i > 0 && chars.get(i - 1).is_some_and(|c| is_word(*c)) { + match chars.get(i).copied() { + Some( + '\u{3005}' + | '\u{3006}' + | '\u{3007}' + | '\u{3400}'..='\u{4DBF}' + | '\u{4E00}'..='\u{9FFF}' + | '\u{F900}'..='\u{FAFF}' + | '\u{20000}'..='\u{3FFFF}', + ) => 2, + Some('\u{3041}'..='\u{309F}') => 3, + Some('\u{30A0}'..='\u{30FF}' | '\u{31F0}'..='\u{31FF}' | '\u{FF66}'..='\u{FF9F}') => 4, + Some('\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{AC00}'..='\u{D7AF}') => 5, + Some(c) if c.is_alphanumeric() || c == '_' => 1, + _ => 0, + } +} + +fn is_word(chars: &[char], i: usize) -> bool { + word_class(chars, i) != 0 +} + +/// The start of the word before `i` (skipping spaces and punctuation first). +fn word_left(chars: &[char], mut i: usize) -> usize { + while i > 0 && !is_word(chars, i - 1) { + i -= 1; + } + let class = word_class(chars, i.saturating_sub(1)); + while i > 0 && word_class(chars, i - 1) == class && class != 0 { i -= 1; } i } +/// The end of the word after `i` (skipping spaces and punctuation first). fn word_right(chars: &[char], mut i: usize) -> usize { let n = chars.len(); - while i < n && chars.get(i).is_some_and(|c| !is_word(*c)) { + while i < n && !is_word(chars, i) { i += 1; } - while i < n && chars.get(i).is_some_and(|c| is_word(*c)) { + let class = word_class(chars, i); + while i < n && word_class(chars, i) == class && class != 0 { i += 1; } i @@ -148,7 +181,7 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R } else if word { word_left(&chars, a) } else { - a.saturating_sub(1) + figcraft_text::grapheme_step(&t.characters, a, false) }; (from, a) } else { @@ -157,7 +190,7 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R } else if word { word_right(&chars, a) } else { - (a + 1).min(n) + figcraft_text::grapheme_step(&t.characters, a, true).min(n) }; (a, to) }; @@ -192,8 +225,7 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R // Plain arrows move on screen through right-to-left runs, by char elsewhere. "Left" | "Right" => match layout.as_ref().and_then(|l| l.visual_step(cur, key == "Right")) { Some(i) => i.min(n), - None if key == "Left" => cur.saturating_sub(1), - None => (cur + 1).min(n), + None => figcraft_text::grapheme_step(&t.characters, cur, key == "Right").min(n), }, "Home" if line => 0, "End" if line => n, @@ -317,25 +349,22 @@ pub fn select_word(s: &mut Session, index: usize) -> Result<()> { let chars: Vec = text_of(s, te.id)?.characters.chars().collect(); let mut a = index.min(chars.len()); // Double-clicking just after a word's last char (or at the end) selects that word. - if a > 0 && chars.get(a).is_none_or(|c| !is_word(*c)) && chars.get(a - 1).is_some_and(|c| is_word(*c)) { + if a > 0 && !is_word(&chars, a) && is_word(&chars, a - 1) { a -= 1; } let mut b = a; - let class = |c: char| { - if is_word(c) { - 0 - } else if c.is_whitespace() { - 1 - } else { - 2 - } + // Words by script (see `word_class`), then runs of spaces (10) and of punctuation (11). + let class = |i: usize| match word_class(&chars, i) { + 0 if chars.get(i).is_some_and(|c| c.is_whitespace()) => 10, + 0 => 11, + k => k, }; // A word, a run of spaces or a run of punctuation, like the incumbent; newlines stay unselected. - if let Some(k) = chars.get(a).filter(|c| **c != '\n').map(|c| class(*c)) { - while a > 0 && chars.get(a - 1).is_some_and(|c| *c != '\n' && class(*c) == k) { + if let Some(k) = chars.get(a).filter(|c| **c != '\n').map(|_| class(a)) { + while a > 0 && chars.get(a - 1).is_some_and(|c| *c != '\n') && class(a - 1) == k { a -= 1; } - while b < chars.len() && chars.get(b).is_some_and(|c| *c != '\n' && class(*c) == k) { + while b < chars.len() && chars.get(b).is_some_and(|c| *c != '\n') && class(b) == k { b += 1; } } diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 6a1aa0e..31e72e7 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -301,7 +301,7 @@ fn text_styles_and_runs() { "style": {"fontFamily": "Inter", "fontPostScriptName": "Inter-Regular", "fontStyle": "Regular", "fontWeight": 400, "fontSize": 24, "lineHeightPx": 29, "lineHeightUnit": "INTRINSIC_%", "letterSpacing": 0, "textAlignHorizontal": "CENTER", "textAlignVertical": "CENTER", "textAutoResize": "HEIGHT", "textCase": "UPPER", "textDecoration": "UNDERLINE", - "paragraphSpacing": 8, "paragraphIndent": 4, "openTypeFlags": {"LIGA": 0, "ss01": 1}}, + "paragraphSpacing": 8, "paragraphIndent": 4, "leadingTrim": "CAP_HEIGHT", "openTypeFlags": {"LIGA": 0, "ss01": 1}}, // UTF-16 code units: the emoji takes two. "characterStyleOverrides": [0, 1, 1, 2], "styleOverrideTable": {"1": {"fontWeight": 700}, "2": {"fills": [solid(1.0, 0.0, 0.0)], "hyperlink": {"type": "URL", "url": "https://example.com"}}}, @@ -322,6 +322,7 @@ fn text_styles_and_runs() { assert_eq!((td.style.case, td.style.decoration), (TextCase::Upper, TextDecoration::Underline)); assert_eq!((td.align_h, td.align_v, td.auto_resize), (TextAlignH::Center, TextAlignV::Middle, TextAutoResize::Height)); assert_eq!((td.paragraph_spacing, td.paragraph_indent), (8.0, 4.0)); + assert_eq!(td.leading_trim, figcraft_doc::LeadingTrim::CapHeight); assert_eq!(td.features.get("liga"), Some(&false)); assert_eq!(td.features.get("ss01"), Some(&true)); assert_eq!(td.list, ListStyle::Bulleted); diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 6faaa33..a4ee8a6 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -57,6 +57,9 @@ fn paragraph(s: &Value, td: &mut TextData) { td.max_lines = num(s, "maxLines").filter(|n| *n >= 1.0).map_or(0, |n| n.min(10_000.0) as u32); td.paragraph_spacing = len(s, "paragraphSpacing").unwrap_or(0.0); td.paragraph_indent = len(s, "paragraphIndent").unwrap_or(0.0); + if text(s, "leadingTrim") == Some("CAP_HEIGHT") { + td.leading_trim = figcraft_doc::LeadingTrim::CapHeight; + } if let Some(flags) = s.get("openTypeFlags").and_then(Value::as_object) { for (tag, on) in flags.iter().take(64) { if figcraft_doc::valid_feature_tag(&tag.to_ascii_lowercase()) { diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 44a4af9..273ff12 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -14,4 +14,5 @@ figcraft-doc = { workspace = true } skrifa = { workspace = true } harfrust = { workspace = true } unicode-bidi = { workspace = true } +unicode-segmentation = { workspace = true } log = { workspace = true } diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs new file mode 100644 index 0000000..04eff79 --- /dev/null +++ b/crates/text/src/behaviour_tests.rs @@ -0,0 +1,342 @@ +//! Text behaviour suite, written from the public documentation of the target app's text settings +//! (and, for line breaking, from UAX #14 and the CJK line-start/line-end rules it describes). Each +//! case gives the rule, the numbers it implies and a one-line derivation. Where the documentation +//! is silent the case says so and pins today's behaviour. +//! +//! Metrics cases read the face actually used (any default face: craft-fonts or the system) and +//! derive the expected numbers from its own ascent, descent, line gap and cap height, so they hold +//! on every machine that has a font; with none they skip. Break and caret rules are font-free. + +use super::*; +use crate::breaks::{break_between, grapheme_starts}; +use figcraft_doc::{LeadingTrim, LetterSpacing, LineHeight, TextAlignH, TextAutoResize, TextData, TextDecoration}; + +/// The default face once system fonts are indexed (a Latin face where the system has one, so CJK +/// and emoji come from fallback faces), or None (skip) on a machine without fonts. +fn face() -> Option> { + scan_system_fonts(); + db().write().unwrap().default_face() +} + +/// A 20 px text in the default face, with the given resize mode. +fn text(s: &str, resize: TextAutoResize) -> TextData { + let mut t = TextData::new(s); + if let Some(f) = face() { + (t.style.font_family, t.style.font_style) = (f.family.clone(), f.style.clone()); + } + t.style.font_size = 20.0; + t.auto_resize = resize; + t +} + +/// The chars of each line. +fn line_texts(t: &TextData, l: &TextLayout) -> Vec { + let chars: Vec = t.characters.chars().collect(); + l.lines.iter().map(|ln| chars.get(ln.char_start..ln.char_end).unwrap_or(&[]).iter().collect::()).collect() +} + +const EPS: f64 = 1e-6; + +#[test] +fn line_height_auto_percent_and_pixels() { + let Some(f) = face() else { return }; + let k = 20.0 / f.upem; + let auto = (f.ascent + f.descent + f.line_gap.max(0.0)) * k; + // (line height, lines, expected height, derivation) + let cases: [(LineHeight, &str, f64, &str); 5] = [ + (LineHeight::Auto, "A", auto, "Auto: the font's own line height (ascent + descent + line gap)"), + (LineHeight::Auto, "A\nB\nC", 3.0 * auto, "every line takes the same Auto height"), + (LineHeight::Percent(150.0), "A\nB", 60.0, "150% of 20 px = 30 px per line, two lines"), + (LineHeight::Pixels(40.0), "A\nB\nC", 120.0, "40 px per line, three lines"), + (LineHeight::Pixels(10.0), "A\nB", 20.0, "a line height below the font size still spaces lines by it"), + ]; + for (lh, s, want, why) in cases { + let mut t = text(s, TextAutoResize::WidthAndHeight); + t.style.line_height = lh; + let l = layout(&t, None, None); + assert!((l.height - want).abs() < EPS, "{why}: {} != {want}", l.height); + } +} + +#[test] +fn leading_is_split_evenly_above_and_below_each_line() { + let Some(f) = face() else { return }; + let k = 20.0 / f.upem; + let (asc, desc) = (f.ascent * k, f.descent * k); + for (lh, why) in [ + (LineHeight::Pixels(40.0), "40 px: half of (40 − ascent − descent) above the first line too"), + (LineHeight::Percent(200.0), "200% = 40 px, same split"), + (LineHeight::Pixels(12.0), "negative leading is split the same way (glyphs overflow both edges)"), + ] { + let mut t = text("A\nB", TextAutoResize::WidthAndHeight); + t.style.line_height = lh; + let l = layout(&t, None, None); + let px = lh.resolve(20.0, 1.0); + for (i, line) in l.lines.iter().enumerate() { + let want = i as f64 * px + (px - asc - desc) / 2.0 + asc; + assert!((line.baseline - want).abs() < EPS, "{why}: line {i} baseline {} != {want}", line.baseline); + } + } +} + +#[test] +fn letter_spacing_percent_and_pixels() { + if face().is_none() { + return; + } + let plain = layout(&text("abc", TextAutoResize::WidthAndHeight), None, None).width; + // (spacing, extra width for 3 chars, derivation) + let cases: [(LetterSpacing, f64, &str); 4] = [ + (LetterSpacing::Percent(10.0), 6.0, "10% of 20 px = 2 px after each of 3 chars (the last one too: documentation silent)"), + (LetterSpacing::Pixels(3.0), 9.0, "3 px after each char"), + (LetterSpacing::Percent(-5.0), -3.0, "negative spacing tightens: −1 px per char"), + (LetterSpacing::Pixels(0.0), 0.0, "0 changes nothing"), + ]; + for (ls, extra, why) in cases { + let mut t = text("abc", TextAutoResize::WidthAndHeight); + t.style.letter_spacing = ls; + let w = layout(&t, None, None).width; + assert!((w - plain - extra).abs() < 1e-3, "{why}: {w} vs {plain} + {extra}"); + } +} + +#[test] +fn paragraph_spacing_and_indent() { + if face().is_none() { + return; + } + let mut t = text("A\nB\nC", TextAutoResize::WidthAndHeight); + t.style.line_height = LineHeight::Pixels(30.0); + t.paragraph_spacing = 10.0; + let l = layout(&t, None, None); + // Between paragraphs only: 3 × 30 + 2 × 10, nothing after the last. + assert!((l.height - 110.0).abs() < EPS, "{}", l.height); + assert!((l.lines[1].top - 40.0).abs() < EPS && (l.lines[2].top - 80.0).abs() < EPS); + + // Indent: the first line of every paragraph, on left-aligned text only. + let words = "one two three four five six seven eight nine ten"; + for (align, want, why) in [ + (TextAlignH::Left, 24.0, "left: the first line starts 24 px in"), + (TextAlignH::Center, 0.0, "documented: indent applies to left-aligned text only"), + (TextAlignH::Right, 0.0, "right-aligned (left-to-right paragraph): no indent"), + (TextAlignH::Justified, 0.0, "justified isn't left-aligned: no indent"), + ] { + let mut t = text(&format!("{words}\n{words}"), TextAutoResize::Height); + t.paragraph_indent = 24.0; + t.align_h = align; + let mut plain = t.clone(); + plain.paragraph_indent = 0.0; + let (l, p) = (layout(&t, Some(160.0), None), layout(&plain, Some(160.0), None)); + let first_x = |l: &TextLayout, li: usize| l.glyphs[l.lines[li].glyph_start].x; + assert!((first_x(&l, 0) - first_x(&p, 0) - want).abs() < 1e-3, "{why}"); + assert!((first_x(&l, 1) - first_x(&p, 1)).abs() < 1e-3, "{why}: wrapped lines don't indent"); + let second_para = l.lines.iter().position(|ln| ln.char_start == words.chars().count() + 1).unwrap(); + let second_plain = p.lines.iter().position(|ln| ln.char_start == words.chars().count() + 1).unwrap(); + assert!((first_x(&l, second_para) - first_x(&p, second_plain) - want).abs() < 1e-3, "{why}: every paragraph"); + } +} + +#[test] +fn resizing_auto_width_auto_height_fixed_and_truncate() { + if face().is_none() { + return; + } + let s = "one two three four five six seven eight"; + // Auto width: one line per paragraph whatever the box. + assert_eq!(layout(&text(s, TextAutoResize::WidthAndHeight), Some(50.0), None).lines.len(), 1); + // Auto height: wraps to the width and grows. + let l = layout(&text(s, TextAutoResize::Height), Some(80.0), None); + assert!(l.lines.len() >= 3 && l.lines.iter().all(|ln| ln.width <= 80.0 + EPS)); + // Fixed: wraps the same, the overflow stays (not truncated). + let fixed = layout(&text(s, TextAutoResize::None), Some(80.0), Some(20.0)); + assert_eq!(fixed.lines.len(), l.lines.len()); + assert!(!fixed.truncated); + // Truncate: lines that don't fit the box are hidden and the last one ends in an ellipsis. + let one = layout(&text(s, TextAutoResize::Truncate), Some(80.0), Some(l.lines[0].height + 1.0)); + assert!(one.truncated && one.lines.len() == 1); + assert!(one.glyphs.last().is_some_and(|g| g.char_start == g.char_end), "ellipsis glyph last"); + // Max lines: the number of lines before it truncates. + let mut t = text(s, TextAutoResize::Truncate); + t.max_lines = 2; + let two = layout(&t, Some(80.0), Some(1000.0)); + assert!(two.truncated && two.lines.len() == 2); +} + +#[test] +fn vertical_trim_cap_height_to_baseline() { + let Some(f) = face() else { return }; + let k = 20.0 / f.upem; + let cap = f.cap_height * k; + for (s, lh, want, why) in [ + ("A", LineHeight::Pixels(40.0), cap, "one line: cap height to baseline = the cap height"), + ("A\nB\nC", LineHeight::Pixels(40.0), cap + 80.0, "first cap height, then the baselines 40 px apart"), + ("A\nB", LineHeight::Auto, cap + (f.ascent + f.descent + f.line_gap.max(0.0)) * k, "Auto line height, same rule"), + ] { + let mut t = text(s, TextAutoResize::WidthAndHeight); + t.style.line_height = lh; + t.leading_trim = LeadingTrim::CapHeight; + let l = layout(&t, None, None); + assert!((l.height - want).abs() < 1e-6, "{why}: {} != {want}", l.height); + assert!((l.lines[0].baseline - cap).abs() < 1e-6, "{why}: first baseline sits a cap height below the top"); + // Trimmed and untrimmed glyphs keep their spacing; only the box edges move. + t.leading_trim = LeadingTrim::None; + let u = layout(&t, None, None); + let shift = u.lines[0].baseline - l.lines[0].baseline; + for (a, b) in u.glyphs.iter().zip(&l.glyphs) { + assert!((a.y - b.y - shift).abs() < 1e-9, "{why}"); + } + } + // Truncation counts the trimmed bottom: two 40 px lines need cap + 40 px, not 80. + let mut t = text("A\nB\nC", TextAutoResize::Truncate); + t.style.line_height = LineHeight::Pixels(40.0); + t.leading_trim = LeadingTrim::CapHeight; + let l = layout(&t, Some(200.0), Some(cap + 40.0 + 0.1)); + assert_eq!(l.lines.len(), 2, "two trimmed lines fit"); +} + +#[test] +fn decorations_use_the_fonts_metrics() { + let Some(f) = face() else { return }; + let k = 20.0 / f.upem; + for (deco, pos, why) in [ + (TextDecoration::Underline, f.underline_pos, "underline: the font's underline offset below the baseline"), + (TextDecoration::Strikethrough, f.strike_pos, "strikethrough: the font's strikeout offset above it"), + ] { + let mut t = text("漢字 Hello", TextAutoResize::WidthAndHeight); + t.style.decoration = deco; + let l = layout(&t, None, None); + assert_eq!(l.decorations.len(), 1, "{why}: one line through mixed scripts"); + let d = &l.decorations[0]; + let th = (f.underline_thickness * k).max(0.5); + assert!((d.rect.height() - th).abs() < 1e-9, "{why}: the font's thickness"); + assert!(((d.rect.y0 + d.rect.y1) / 2.0 - (l.lines[0].baseline - pos * k)).abs() < 1e-9, "{why}"); + } +} + +#[test] +fn fallback_fonts_dont_move_the_baseline() { + // Documentation silent on fallback faces; rule chosen: a style's line metrics come from its own + // font, so a line with CJK (drawn by a fallback face) is spaced like a Latin one. + if face().is_none() { + return; + } + for lh in [LineHeight::Auto, LineHeight::Pixels(32.0), LineHeight::Percent(140.0)] { + let mut t = text("Ab\n漢字かな\nAb 한국어 😀\nAb", TextAutoResize::WidthAndHeight); + t.style.line_height = lh; + let l = layout(&t, None, None); + let first = &l.lines[0]; + for line in &l.lines { + assert!((line.height - first.height).abs() < 1e-9, "{lh:?}: same height"); + assert!((line.baseline - line.top - (first.baseline - first.top)).abs() < 1e-9, "{lh:?}: same baseline offset"); + } + } +} + +#[test] +fn break_opportunities() { + // (before, after, may break between, derivation) + let cases: [(char, char, bool, &str); 22] = [ + ('a', 'b', false, "inside a Latin word: never"), + (' ', 'b', true, "after a space"), + ('a', ' ', false, "before a space: the space hangs at the line's end"), + ('-', 'b', true, "after a hyphen"), + ('\u{2014}', 'b', true, "after an em dash"), + ('日', '本', true, "between ideographs"), + ('あ', 'い', true, "between kana"), + ('한', '국', true, "between Hangul syllables (documentation silent on Korean word mode: by syllable, like CSS normal)"), + ('a', '日', true, "Latin then an ideograph"), + ('日', 'a', true, "an ideograph then Latin"), + ('本', '。', false, "kinsoku: an ideographic full stop never starts a line"), + ('本', '、', false, "kinsoku: nor an ideographic comma"), + ('本', '」', false, "kinsoku: nor a closing bracket"), + ('本', 'ー', false, "kinsoku: nor the prolonged sound mark"), + ('キ', 'ャ', false, "kinsoku: nor a small kana"), + ('本', '々', false, "kinsoku: nor an iteration mark"), + ('本', '!', false, "kinsoku: nor full-width punctuation"), + ('「', '日', false, "kinsoku: an opening bracket never ends a line"), + ('(', '日', false, "kinsoku: nor a full-width opening parenthesis"), + ('。', '日', true, "after a full stop"), + ('日', '「', true, "before an opening bracket"), + ('\u{3000}', '日', true, "after an ideographic space"), + ]; + for (a, b, want, why) in cases { + assert_eq!(break_between(a, b), want, "{a}{b}: {why}"); + } +} + +#[test] +fn cjk_lines_wrap_between_ideographs_with_kinsoku() { + if face().is_none() { + return; + } + // Box width cut right after char `fit` (chars 0..fit fit, the next one doesn't): the first line + // and how the second starts. (text, fit, first line, second line starts with, derivation) + let cases: [(&str, usize, &str, &str, &str); 6] = [ + ("一二三四五六", 3, "一二三", "四", "ideographs break anywhere"), + ("一二三。四五", 3, "一二", "三。", "。 can't start a line: 三 moves down with it"), + ("一二「三」", 3, "一二", "「三", "「 can't end a line: it moves down"), + ("日本語abc def", 5, "日本語", "abc", "no break inside a Latin word: before it"), + ("abc 日本語です", 6, "abc 日本", "語", "a space or an ideograph is a break"), + ("한국어 텍스트입니다", 5, "한국어 텍", "스", "Korean breaks between syllables (documentation silent)"), + ]; + for (s, fit, first, second, why) in cases { + let t = text(s, TextAutoResize::Height); + let natural = layout(&t, None, None); + let (fits, next) = (natural.caret(fit).0, natural.caret(fit + 1).0); + assert!(next > fits, "{why}: chars need advances"); + let l = layout(&t, Some(fits + (next - fits) / 4.0), None); + let got = line_texts(&t, &l); + assert_eq!(got[0], first, "{why}"); + assert!(got[1].starts_with(second), "{why}: {got:?}"); + } +} + +#[test] +fn full_width_space_hangs_and_breaks() { + if face().is_none() { + return; + } + let t = text("日本\u{3000}語", TextAutoResize::Height); + let natural = layout(&t, None, None); + let w = natural.caret(2).0 + 0.1; + let l = layout(&t, Some(w), None); + assert_eq!(line_texts(&t, &l), ["日本\u{3000}", "語"], "the ideographic space ends the line and hangs"); + assert!(l.lines[0].width <= w); +} + +#[test] +fn grapheme_clusters() { + let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"; // 5 chars, one cluster + let flag = "\u{1F1EF}\u{1F1F5}"; // two regional indicators + let accent = "e\u{0301}"; // e + combining acute + let thumbs = "\u{1F44D}\u{1F3FD}"; // emoji + skin tone + let s = format!("a{family}b{flag}{accent}{thumbs}\u{2764}\u{FE0F}"); + // Every boundary in order: a | family | b | flag | é | thumbs | heart. + let stops = [0, 1, 6, 7, 9, 11, 13, 15]; + for w in stops.windows(2) { + assert_eq!(grapheme_step(&s, w[0], true), w[1], "forward from {}", w[0]); + assert_eq!(grapheme_step(&s, w[1], false), w[0], "back from {}", w[1]); + } + assert_eq!(grapheme_step(&s, 15, true), 15, "the end stays"); + assert_eq!(grapheme_step(&s, 0, false), 0, "the start stays"); + assert_eq!(grapheme_step(&s, 3, true), 6, "from inside a cluster: its end"); + assert_eq!(grapheme_step(&s, 3, false), 1, "back from inside: its start"); + assert_eq!(grapheme_step("", 0, true), 0); + let starts = grapheme_starts(&s); + assert_eq!(starts.len(), s.chars().count()); + assert_eq!(starts.iter().filter(|b| **b).count(), stops.len() - 1); + assert!(grapheme_starts("plain ascii").iter().all(|b| *b)); +} + +#[test] +fn a_cluster_is_shaped_with_one_face() { + if face().is_none() { + return; + } + // Wherever the base char's face is, its marks and joiners go with it (one glyph run). + for s in ["e\u{0301}", "\u{1F468}\u{200D}\u{1F469}", "漢\u{FE00}"] { + let l = layout(&text(s, TextAutoResize::WidthAndHeight), None, None); + let faces: std::collections::HashSet = l.glyphs.iter().map(|g| g.face.id).collect(); + assert_eq!(faces.len(), 1, "{s:?}"); + } +} diff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs new file mode 100644 index 0000000..3f5458e --- /dev/null +++ b/crates/text/src/breaks.rs @@ -0,0 +1,100 @@ +//! Where lines may break and where carets may stop: break opportunities (a small subset of UAX #14 +//! with the CJK line-start and line-end rules) and grapheme clusters (UAX #29). + +use unicode_segmentation::UnicodeSegmentation; + +/// The break class of a char. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Class { + /// Breakable space: lines break after it (it hangs at the line's end). + Space, + /// Ideographs, kana, Hangul and full-width forms: lines may break before and after each. + Ideo, + /// Opening brackets and quotes: never end a line. + Open, + /// Closing brackets, quotes and punctuation, small kana, the prolonged sound mark and + /// iteration marks: never start a line. + Close, + /// Hyphens, dashes and the slash: lines may break after them. + Hyphen, + /// Everything else (letters, digits, other symbols): no break inside a run of them. + Other, +} + +/// Opening brackets and quotes (line-end prohibited). +const OPEN: &str = "([{‘“〈《「『【〔〖〘〚〝([{「"; +/// Closing brackets, quotes and punctuation, iteration marks, small kana and the prolonged sound +/// mark (line-start prohibited; the small katakana extensions U+31F0..U+31FF are a range below). +const CLOSE: &str = + ")]},.:;!?’”‥…‼⁇⁈⁉、。々〉》」』】〕〗〙〛〜〞〟〻ぁぃぅぇぉっゃゅょゎゕゖゝゞ゠ァィゥェォッャュョヮヵヶ・ーヽヾ!),.:;?]}。」、・ー"; + +fn class(c: char) -> Class { + if c.is_ascii_alphanumeric() { + return Class::Other; + } + if OPEN.contains(c) { + return Class::Open; + } + if CLOSE.contains(c) || ('\u{31F0}'..='\u{31FF}').contains(&c) { + return Class::Close; + } + match c { + ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{200B}' => Class::Space, + '-' | '/' | '\u{2010}' | '\u{2013}' | '\u{2014}' => Class::Hyphen, + '\u{2E80}'..='\u{2FFF}' + | '\u{3003}'..='\u{33FF}' + | '\u{3400}'..='\u{4DBF}' + | '\u{4E00}'..='\u{9FFF}' + | '\u{A960}'..='\u{A97F}' + | '\u{AC00}'..='\u{D7AF}' + | '\u{F900}'..='\u{FAFF}' + | '\u{FE30}'..='\u{FE4F}' + | '\u{FF00}'..='\u{FFEF}' + | '\u{20000}'..='\u{3FFFF}' => Class::Ideo, + _ => Class::Other, + } +} + +/// Whether a line may break between `a` and the `b` after it (the break goes before `b`). +pub(crate) fn break_between(a: char, b: char) -> bool { + use Class::*; + match (class(a), class(b)) { + // Spaces hang at the end of the line before them; kinsoku: no closing mark starts a line, + // no opening mark ends one. + (_, Space | Close) | (Open, _) => false, + (Space | Hyphen | Ideo, _) | (_, Ideo) => true, + _ => false, + } +} + +/// Whether char `c` is a breakable space (it hangs at a line's end and doesn't count for its width). +pub(crate) fn is_space(c: char) -> bool { + class(c) == Class::Space +} + +/// For each char of `text`, whether it starts a grapheme cluster (a user-perceived character: +/// an emoji sequence, a letter with its combining marks). +pub(crate) fn grapheme_starts(text: &str) -> Vec { + if text.is_ascii() && !text.contains('\r') { + return vec![true; text.len()]; + } + let mut starts = Vec::with_capacity(text.len()); + for g in text.graphemes(true) { + starts.extend(g.chars().enumerate().map(|(k, _)| k == 0)); + } + starts +} + +/// The char index of the grapheme cluster boundary after (`forward`) or before char `index` in +/// `text`: where an arrow key, Delete or Backspace moves the caret to. +pub fn grapheme_step(text: &str, index: usize, forward: bool) -> usize { + let mut at = 0; + for g in text.graphemes(true) { + let next = at + g.chars().count(); + if (forward && index < next) || (!forward && index <= next) { + return if forward { next } else { at }; + } + at = next; + } + at.min(index) +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 49411a3..3a31a88 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -3,7 +3,7 @@ use std::sync::Arc; -use figcraft_doc::{LineHeight, ListStyle, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, valid_feature_tag}; +use figcraft_doc::{LeadingTrim, ListStyle, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, valid_feature_tag}; use figcraft_geom::{Affine, BezPath, Rect}; use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation}; @@ -106,10 +106,7 @@ struct Item { impl Item { fn is_space(&self) -> bool { - matches!(self.ch, ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{200B}') - } - fn break_after(&self) -> bool { - self.is_space() || matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/') || is_cjk(self.ch) + crate::breaks::is_space(self.ch) } } @@ -171,10 +168,6 @@ pub(crate) fn visual_order(levels: &[u8]) -> Vec { order } -fn is_cjk(c: char) -> bool { - matches!(c as u32, 0x3040..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF) -} - /// Lay out `text` for a box of `box_width` (used when the text wraps or aligns; for auto width /// pass `None`). `box_height` is used for truncation only. pub fn layout(text: &TextData, box_width: Option, box_height: Option) -> TextLayout { @@ -212,6 +205,8 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let bidi = Bidi::of(&text.characters); let level_at = |i: usize| bidi.as_ref().and_then(|b| b.levels.get(i)).copied().unwrap_or(0); let rtl_para_at = |i: usize| bidi.as_ref().and_then(|b| b.rtl_para.get(i)).copied().unwrap_or(false); + // A grapheme cluster (emoji sequence, letter with marks) is shaped with one face. + let cluster_start = crate::breaks::grapheme_starts(&text.characters); let mut items: Vec = Vec::with_capacity(chars.len()); for (si, (start, end, _)) in spans.iter().enumerate() { let st_idx = span_style.get(si).copied().unwrap_or(0); @@ -233,7 +228,9 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let level = level_at(seg_start); let mut seg_end = seg_start + 1; while seg_end < *end - && chars.get(seg_end).is_some_and(|c| *c != '\n' && first != '\n' && face_for(*c).id == face.id) + && chars.get(seg_end).is_some_and(|c| { + *c != '\n' && first != '\n' && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id) + }) && level_at(seg_end) == level { seg_end += 1; @@ -265,7 +262,12 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) // applies to plain paragraphs only. Right-to-left paragraphs mirror both: the gutter and the // indent are on the right, the text ends where they start. let marker_width = list_gutter(text.list, &chars, styles.first(), faces.first().cloned().flatten().as_ref()); - let indent = if text.list == ListStyle::None && text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 }; + // Paragraph indent applies to start-aligned text only (left, or right for a right-to-left paragraph). + let indent_px = if text.list == ListStyle::None && text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 }; + let indent_at = |char_idx: usize| { + let start_aligned = if rtl_para_at(char_idx) { text.align_h == TextAlignH::Right } else { text.align_h == TextAlignH::Left }; + if start_aligned { indent_px } else { 0.0 } + }; // Line breaking. let wrap = match text.auto_resize { @@ -289,7 +291,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) para_first_line = true; continue; } - let line_indent = marker_width + if para_first_line { indent } else { 0.0 }; + let line_indent = marker_width + if para_first_line { indent_at(it.char_start) } else { 0.0 }; let w = it.adv; if let Some(maxw) = wrap && x + w > maxw - line_indent + 1e-6 @@ -306,7 +308,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) continue; } x += w; - if it.break_after() { + if items.get(i + 1).is_some_and(|next| crate::breaks::break_between(it.ch, next.ch)) { last_break = Some(i); } i += 1; @@ -322,39 +324,41 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let mut y = 0.0; let mut para_start_line = true; let max_lines = if text.max_lines > 0 && matches!(text.auto_resize, TextAutoResize::Truncate) { text.max_lines as usize } else { usize::MAX }; + // A style's vertical metrics (ascent, descent, line height, cap height) come from its own + // font, never a fallback face, so lines mixing scripts keep the spacing of lines that don't. + let metrics = |st_idx: usize| -> [f64; 4] { + let st = styles.get(st_idx).unwrap_or(&default_style); + let size = sanitize_size(st.font_size); + match faces.get(st_idx).and_then(Option::as_ref).or(default_face.as_ref()) { + Some(f) => { + let k = size / f.upem; + [f.ascent * k, f.descent * k, st.line_height.resolve(size, natural_ratio(f)), f.cap_height * k] + } + None => [size * 0.8, size * 0.2, st.line_height.resolve(size, 1.2), size * 0.7], + } + }; + let mut first_cap = 0.0; for (li, (s, e, para_end)) in lines.iter().enumerate() { if li >= max_lines { out.truncated = true; break; } // Metrics from the styles present on the line (or the style at the break for empty lines). - let mut asc: f64 = 0.0; - let mut desc: f64 = 0.0; - let mut lh: f64 = 0.0; - let mut any = false; - for it in items.get(*s..*e).unwrap_or(&[]) { - let Some(st) = styles.get(it.style) else { continue }; - let size = sanitize_size(st.font_size); - let k = size / it.face.upem; - asc = asc.max(it.face.ascent * k); - desc = desc.max(it.face.descent * k); - lh = lh.max(st.line_height.resolve(size, natural_ratio(&it.face))); - any = true; - } - if !any { - let st = items.get(s.saturating_sub(1)).and_then(|it| styles.get(it.style)).unwrap_or(&default_style); - let size = sanitize_size(st.font_size); - if let Some(f) = items.get(s.saturating_sub(1)).map(|it| it.face.clone()).or_else(|| default_face.clone()) { - let k = size / f.upem; - asc = f.ascent * k; - desc = f.descent * k; - lh = st.line_height.resolve(size, natural_ratio(&f)); - } else { - asc = size * 0.8; - desc = size * 0.2; - lh = size * 1.2; + let [mut asc, mut desc, mut lh, mut cap] = [0.0_f64; 4]; + let mut seen = usize::MAX; + for st in items.get(*s..*e).unwrap_or(&[]).iter().map(|it| it.style) { + if st != seen { + seen = st; + let m = metrics(st); + (asc, desc, lh, cap) = (asc.max(m[0]), desc.max(m[1]), lh.max(m[2]), cap.max(m[3])); } } + if seen == usize::MAX { + [asc, desc, lh, cap] = metrics(items.get(s.saturating_sub(1)).map_or(0, |it| it.style)); + } + if li == 0 { + first_cap = cap; + } if para_start_line && li > 0 { y += text.paragraph_spacing.max(0.0); } @@ -365,8 +369,9 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) vis_end -= 1; } let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum(); - let line_indent = marker_width + if para_start_line { indent } else { 0.0 }; - let rtl_line = rtl_para_at(items.get(*s).map(|it| it.char_start).unwrap_or(0)); + let first_char = items.get(*s).map(|it| it.char_start).unwrap_or(0); + let rtl_line = rtl_para_at(first_char); + let line_indent = marker_width + if para_start_line { indent_at(first_char) } else { 0.0 }; let gstart = out.glyphs.len(); // Justify: extra space per gap. let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.is_space()).count(); @@ -494,21 +499,34 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) y += lh; para_start_line = *para_end; } - out.height = y; out.width = out.lines.iter().map(|l| l.width).fold(0.0, f64::max); + // Vertical trim: the box starts at the first line's cap height and ends at the last baseline. + let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none(); + if trim && let Some(first) = out.lines.first() { + let shift = first.baseline - first_cap; + for l in &mut out.lines { + l.top -= shift; + l.baseline -= shift; + } + for g in &mut out.glyphs { + g.y -= shift; + } + } + let bottom = |l: &Line| if trim { l.baseline } else { l.top + l.height }; + out.height = out.lines.last().map_or(y, bottom).max(0.0); // Truncation by height. if matches!(text.auto_resize, TextAutoResize::Truncate) && let Some(h) = box_height && out.height > h + 0.5 { - let keep = out.lines.iter().take_while(|l| l.top + l.height <= h + 0.5).count().max(1); + let keep = out.lines.iter().take_while(|l| bottom(l) <= h + 0.5).count().max(1); if keep < out.lines.len() { out.lines.truncate(keep); let gend = out.lines.last().map(|l| l.glyph_end).unwrap_or(0); out.glyphs.truncate(gend); out.truncated = true; - out.height = out.lines.last().map(|l| l.top + l.height).unwrap_or(0.0); + out.height = out.lines.last().map_or(0.0, bottom); } } if out.truncated { @@ -560,8 +578,10 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) { let x0 = g0.x; let x1 = last.x + last.advance; - let f = &g0.face; - let s = g0.size / f.upem; + // The style's own font and size (not a fallback face or a small-cap scale), so the + // line runs at one height and weight through mixed scripts. + let f = faces.get(g0.style).and_then(Option::as_ref).unwrap_or(&g0.face); + let s = sanitize_size(st.font_size) / f.upem; let th = (f.underline_thickness * s).max(0.5); let yy = match st.decoration { TextDecoration::Underline => line.baseline - f.underline_pos * s, @@ -576,7 +596,6 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) if let Some(tp) = &text.path { crate::onpath::place(&mut out, tp, text.align_h); } - let _ = LineHeight::Auto; out } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 542c694..9654615 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -6,11 +6,13 @@ //! fills [`Glyph::path`]s and the editor uses [`TextLayout::caret`] / [`TextLayout::hit`]. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod breaks; pub mod display; pub mod fontdb; mod layout; mod onpath; +pub use breaks::grapheme_step; pub use fontdb::{ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, no_coords, scan_system_fonts, system_fonts_scanned, @@ -141,6 +143,8 @@ pub fn auto_size(n: &Node) -> Option<(f64, f64)> { } } +#[cfg(test)] +mod behaviour_tests; #[cfg(test)] mod cache_tests; #[cfg(test)] diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 2139267..166b85a 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -170,6 +170,7 @@ name = "English" "Cancel" = "Cancel" "Canvas" = "Canvas" "Canvas stacking" = "Canvas stacking" +"Cap height to baseline" = "Cap height to baseline" "Case" = "Case" "Case-sensitive forms" = "Case-sensitive forms" "Categories" = "Categories" @@ -787,6 +788,7 @@ name = "English" "Panels" = "Panels" "Paper" = "Paper" "Paragraph indent" = "Paragraph indent" +"Paragraph indent applies to left-aligned text" = "Paragraph indent applies to left-aligned text" "Paragraph spacing" = "Paragraph spacing" "Pass through" = "Pass through" "Paste a team library link (a view link)" = "Paste a team library link (a view link)" @@ -1037,6 +1039,7 @@ name = "English" "Splay" = "Splay" "Spread" = "Spread" "Square" = "Square" +"Standard" = "Standard" "Standard ligatures" = "Standard ligatures" "Star" = "Star" "Stars" = "Stars" @@ -1181,6 +1184,7 @@ name = "English" "Vertical hex" = "Vertical hex" "Vertical padding" = "Vertical padding" "Vertical spacing" = "Vertical spacing" +"Vertical trim" = "Vertical trim" "View" = "View" "View link" = "View link" "View link copied" = "View link copied" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 31976b5..a7b29ee 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -171,6 +171,7 @@ status = "draft — needs native review" "Cancel" = "Cancelar" "Canvas" = "Lienzo" "Canvas stacking" = "Apilado en el lienzo" +"Cap height to baseline" = "De la altura de mayúsculas a la línea base" "Case" = "Mayúsculas y minúsculas" "Case-sensitive forms" = "Formas sensibles a mayúsculas" "Categories" = "Categorías" @@ -788,6 +789,7 @@ status = "draft — needs native review" "Panels" = "Paneles" "Paper" = "Papel" "Paragraph indent" = "Sangría de párrafo" +"Paragraph indent applies to left-aligned text" = "La sangría de párrafo se aplica al texto alineado a la izquierda" "Paragraph spacing" = "Espacio entre párrafos" "Pass through" = "Atravesar" "Paste a team library link (a view link)" = "Pega el enlace de una biblioteca de equipo (de lectura)" @@ -1038,6 +1040,7 @@ status = "draft — needs native review" "Splay" = "Apertura" "Spread" = "Extensión" "Square" = "Cuadrado" +"Standard" = "Estándar" "Standard ligatures" = "Ligaduras estándar" "Star" = "Estrella" "Stars" = "Estrellas" @@ -1182,6 +1185,7 @@ status = "draft — needs native review" "Vertical hex" = "Hexágono vertical" "Vertical padding" = "Margen interno vertical" "Vertical spacing" = "Espaciado vertical" +"Vertical trim" = "Recorte vertical" "View" = "Ver" "View link" = "Enlace de lectura" "View link copied" = "Enlace de lectura copiado" diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index eb20e50..899bcd5 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -438,14 +438,26 @@ fn type_settings( } ui.add_space(6.0); let indent = common(doc, ids, |n| n.kind.text().map(|t| t.paragraph_indent.to_bits())).flatten().map(f64::from_bits); + // Indent applies to start-aligned text only; elsewhere the field stays visible but off. + let start_aligned = + common(doc, ids, |n| n.kind.text().map(|t| t.align_h != TextAlignH::Center && t.align_h != TextAlignH::Justified)).flatten(); ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new(tr("Paragraph indent")).font(regular(11.0)).color(t.text))); - if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = - widgets::number_field(ui, Id::new("ty_indent"), Lead::None, indent, "", w - 118.0, 1.0) - { - app.set_props(ids, json!({"paragraphIndent": v.clamp(0.0, 10_000.0)})); - } + ui.add_enabled_ui(start_aligned != Some(false), |ui| { + ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new(tr("Paragraph indent")).font(regular(11.0)).color(t.text))); + let field = widgets::number_field(ui, Id::new("ty_indent"), Lead::None, indent, "", w - 118.0, 1.0); + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = field { + app.set_props(ids, json!({"paragraphIndent": v.clamp(0.0, 10_000.0)})); + } + }) + .response + .on_disabled_hover_text(tr("Paragraph indent applies to left-aligned text")); }); + caption(ui, tr("Vertical trim")); + let trim = common(doc, ids, |n| n.kind.text().map(|t| usize::from(t.leading_trim == LeadingTrim::CapHeight))).flatten(); + let items = [(tr("Standard"), tr_noop("Standard")), ("Ag", tr_noop("Cap height to baseline"))]; + if let Some(i) = text_seg(ui, "trim", &items, trim, w) { + app.set_props(ids, json!({"leadingTrim": if i == 1 { "capHeight" } else { "none" }})); + } let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten(); caption(ui, tr("Truncate text")); let active = resize.map(|r| usize::from(r == TextAutoResize::Truncate)); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3f3de39..75a5366 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -21,7 +21,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) | | Mainstream practitioner | **~46%** | estimated ([below](#mainstream-practitioner): weekly areas 67.8% × discounts 0.9 × 0.9 × 0.85) | | Remaining to beta | **~173–310 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~323–621 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~321–618 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -103,7 +103,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 | | 8 | Frames, groups, sections, masks | 5 | 95% | 75% | Visual QA with real files | 2–4 | | 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files; wrapped-line content alignment (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around and cell alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | -| 10 | Text | 8 | 85% | 64% | Spell check, CJK text QA, real-file text metrics (text on a path landed) | 7–13 | +| 10 | Text | 8 | 87% | 68% | Spell check, real-file text metrics, list spacing, hanging punctuation, underline styles (behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed) | 5–10 | | 11 | Components & variants | 9 | 95% | 68% | Real (not synthetic) libraries, `.fig` libraries, component playground ([components-parity](components-parity.md)) | 6–12 | | 12 | Styles & variables | 5 | 92% | 72% | New library variables reaching extended collections on their own, extending an extended collection, real-library QA (extended collections, variables import/export, gradient stops and variant properties bound landed) | 1–2 | | 13 | Prototyping | 6 | 88% | 70% | Video triggers and media actions, gamepad triggers, real prototype QA ([prototyping-parity](prototyping-parity.md)) | 5–9 | @@ -115,8 +115,8 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **165–335** | -| | Core editor only (1–16, ÷94) | 94 | ~91% | ~68% | | 60–116 | +| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **163–332** | +| | Core editor only (1–16, ÷94) | 94 | ~91% | ~68% | | 58–113 | ## By dimension (ready for real work) @@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | | Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~49%** | **~323–621** | | | +| **Ready for real work** | 100 | **~49%** | **~321–618** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -281,7 +281,17 @@ Hours in this narrative are the builders' estimates; the tables above supersede paragraph's font, renames start with the name selected and centred, Max lines shows while truncating. Right-to-left paragraphs mirror lists and indents: bullets and numbers (".1") on the right, wrapped lines hanging from the right, the paragraph indent on the right, trailing - spaces hanging outside the line (canvas, PNG, PDF and SVG). *Left:* visual QA. 0.5 h. + spaces hanging outside the line (canvas, PNG, PDF and SVG). A behaviour suite from the public + documentation (`crates/text/src/behaviour_tests.rs`, `crates/engine/src/text_behaviour_tests.rs`): + Auto / % / px line heights with the leading split above and below every line, letter spacing in + % and px, paragraph spacing and indent (start-aligned text only), resizing and truncation, + decorations from the font's metrics; CJK line breaking (between ideographs, kana and Hangul, no + break inside Latin words, kinsoku for closing and opening punctuation, small kana and the + prolonged sound mark); a style's metrics from its own font, not a fallback; carets, Delete and + Backspace by grapheme cluster; double-click and word moves by script run; IME composition with + CJK; vertical trim (cap height to baseline). *Left:* visual QA; list spacing, hanging quotes and + lists, underline style/offset/thickness/skip ink (documented, not modelled); Korean keeps + breaking by syllable and Han runs aren't split by a dictionary (documentation silent). 0.5 h. 3. **Auto layout and sections depth**: *landed:* on-canvas padding/gap handles (also wrapped-row and grid column/row gaps, grid tracks outlined), min/max width/height (UI, Hug/Fill/Fixed, hugging text wraps at its max), ignore auto layout (absolute position) with constraints, text @@ -580,6 +590,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h | | 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h | | 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Import/export breadth 65% → 80%, ready 35% → 50%, 30–60 h → 8–15 h; file formats 40% → 55%, 30–60 h → 8–15 h; feature breadth ~79% → ~80% (core ~90% → ~91%), areas ready ~57% → ~58% (core ~67% → ~68%); ready for real work ~47% → ~49%; essentials ~53% → ~57% (opening files people send ×0.80 → ×0.88); mainstream ~43% → ~46% (weekly areas 66.8% → 67.8%, file exchange ×0.80 → ×0.85); beta ~195–355 h → ~173–310 h; full parity ~345–666 h → ~323–621 h | | 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 98f51b7..2f74f10 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -60,7 +60,7 @@ interaction feel not yet compared. | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | | | 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 | no spell check | +| Text: point, auto height, fixed; caret, words, lines, IME | done | carets step over whole emoji and accented letters; CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Eyedropper (I) | done | | | Comment (C), Shift+C to show/hide | done | local only | | Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only | From cc30a261e7bec5fac3ef14a3246c8e2bdd176d69 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 11 Oct 2026 23:10:41 +0700 Subject: [PATCH 3/3] M1: layers panel keyboard (box selection, F2 rename, context menu) and stroke styles (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What **Layers panel from the keyboard** (two of the three open items of area 3, plus long-list QA) - **F2 renames** the selected layer (a second key for `object.rename`, next to Cmd/Ctrl+R) from the canvas, after a click on a row, or from a layer row that has the keyboard (F6 mode, where the canvas shortcuts are off, so the layers branch of `a11y::keys` runs it). When the rename ends the row gets the keyboard back (`a11y::refocus_row`); Escape in the field cancels and stays in the panel instead of jumping to the canvas. - **Shift+F10 opens the context menu** (`view.contextMenu`, a new command that only asks the UI): at the focused layer row when the layers panel has the keyboard (F6 or a clicked row), else on the canvas just below the selection (the middle of the canvas with nothing selected). The menu takes the keyboard like the other menus opened from the keyboard (`a11y::popup`), and Escape gives it back to the row or the canvas. The Menu key can't be supported: egui 0.36 has no key for it. - **Keyboard box selection** (`select.area`, F8): for keyboard-only users, a selection box appears around the selection (or in the middle of the view); the arrows move it, Shift+arrows move its far corner (10 screen pixels a press), Enter selects what it covers exactly like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter takes nested layers), Escape or a click drops it. A toast and the canvas's screen-reader description say the box's size and the keys. With a box (`{x, y, width, height, add?, deep?}`) the command selects directly, for agents. The marquee's rules (top-level layers, frames only when covered, the children of the frame the box starts in, the focus view, Shift toggling) moved into `Session::marquee_within` / `marquee_selection`, shared by the pointer marquee and the command. - **Long lists (10k layers)**: a test that a layer near the bottom of a 10k-layer list is revealed (parents expanded, list scrolled) and that F6 lands the keyboard on its row and the arrows walk on from it; the UI bench (`perf_ui_frames`) gained rows for expanding every layer, arrow keys down the expanded list, revealing a layer at the bottom and collapsing everything. Release, 10k layers: first frame with every layer expanded 1.22 ms, an arrow key 1.04 ms, reveal 0.30 ms, collapse all 0.37 ms, idle 0.17 ms. **Stroke styles** (area 6) - What the public documentation describes for strokes was mostly already in: colour styles apply to strokes from the stroke section's style picker (`style.apply {target: "stroke"}`, linked, following `style.update`, one undo step, `style.detach {which: "stroke"}` keeping the colour), and the stroke weight binds to number variables. A test now covers that path. - Missing piece, added: **each side's stroke weight binds to a number variable on its own** (`strokeTopWeight`, `strokeRightWeight`, `strokeBottomWeight`, `strokeLeftWeight`): `node.set` keys (setting or binding one side turns on individual strokes with the other sides at the weight), the stroke-weight variable scope, the stroke override group for instances, and REST `boundVariables` import. The Design panel's per-side fields are bindable, two to a row like the corner radii so a variable's name fits. New strings go through `tr(...)` with `en.toml` / `es.toml` entries, and the two new commands have Spanish labels. Shortcuts are only in `keymap.rs`. Nothing redraws while idle (the box draws only when a key moves it). Selecting from the box leaves no undo step; each stroke edit is one. ## Verified - **Desktop app** over the control channel (`target/debug/figcraft.exe` from this worktree's target dir, `--control `, separate `APPDATA`, keys sent as real `ui.input` key events, stopped by PID): F2 on a selected layer opens its name field in the layers panel (screenshot), typing and Enter rename it; F6 to the layers panel, Shift+F10 opens the menu at the Avatar row with Copy focused (screenshot), Escape gives the keyboard back to the row, F2 + typing + Enter renames it and the row keeps the keyboard ("Profile"); Shift+F10 on the canvas opens the menu for the selected frame with Copy focused (screenshot); F8 shows a box around the selected frame, Shift+arrows stretch it over a second layer (screenshot with the hint toast), Enter selects both; a rectangle with the colour style "Brand/Accent" on its stroke and its top weight bound to a number variable (screenshot: the style chip in the stroke section, the red stroke thicker at the top, the per-side field showing the variable). - **MCP**: `figcraft-cli mcp` (headless): created a paint style, `style.apply {target: "stroke"}`, `object.rename`, bound `strokeLeftWeight` to a variable and ran `select.area` over the layer; `inspect_document` shows the name "Outlined card", `styles.stroke: "S:1"` with the style's colour on the strokes, `strokeSides [2, 2, 2, 6]`, `boundVariables {strokeLeftWeight}`, and one undo step per command. - **Web**: `cargo xtask wasm` passes (part of the gate). - **Performance**: release UI bench at 10k layers (numbers above). - `cargo +1.99.0 xtask ci` passes (fmt, clippy, tests, layers, assets, wasm), after merging #131 and #132. ## Tests - `ui-egui` `layers_keyboard_tests` (3): F2 from the canvas and from a focused row (the row gets the keyboard back, Escape cancels without leaving the panel); Shift+F10 at a focused row, on the canvas below the selection and after a clicked row, the menu taking and giving back the keyboard; F8: the box around the selection, the screen-reader description, Shift+arrows sizing, Enter selecting, arrows moving it without nudging layers, Escape, the middle of the view with nothing selected, a click dropping it. - `ui-egui` `perf_tests::a_layer_revealed_at_the_bottom_of_10k_layers_gets_the_keyboard_on_its_row`, plus the new bench rows. - `engine` `select_area_tests` (2): marquee rules through the command (negative sizes, frames, starting inside a frame, deep, add toggling, empty spots), the UI request with no box, bad boxes refused (the junk sweep covers both new commands too). - `engine` `stroke_style_tests` (2): a colour style on strokes (link, undo, style edits, detach); stroke weight and per-side weights bound to variables following the frame's mode, `node.set` per-side keys, scopes and override groups. ## Not in this PR - The Menu (application) key: egui 0.36 doesn't deliver it. - Enter doesn't rename: on the canvas and a focused row it already selects children / activates the row. - The keyboard box doesn't pan the view when it is moved off screen (zoom and pan keys still work while it shows). - Shift+F10 does nothing while a toolbar or properties control has the keyboard. - Progress docs: area 3 95%/78% → 98%/82%, 2–4 h → 1–2 h; area 6 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; percentages unchanged at this rounding; full parity ~323–621 h → ~322–618 h (target-app-parity, ROADMAP); ui-parity rows (context menus, keyboard box selection, layers from the keyboard); `docs/mcp.md` documents `object.rename` without a name, `view.contextMenu`, `select.area` and stroke styles and variables. --- ROADMAP.md | 13 +- crates/engine/src/cmd/edit.rs | 30 +++ crates/engine/src/cmd/props.rs | 18 +- crates/engine/src/cmd/variables.rs | 2 +- crates/engine/src/cmd/view.rs | 7 + crates/engine/src/keymap.rs | 4 +- crates/engine/src/lib.rs | 4 + crates/engine/src/select_area_tests.rs | 78 ++++++++ crates/engine/src/stroke_style_tests.rs | 77 ++++++++ crates/engine/src/tools.rs | 66 ++++--- crates/format/src/rest/variables.rs | 5 +- crates/ui-egui/locales/en.toml | 2 + crates/ui-egui/locales/es.toml | 4 + crates/ui-egui/src/a11y.rs | 86 +++++++-- crates/ui-egui/src/canvas.rs | 16 +- crates/ui-egui/src/layers.rs | 4 + crates/ui-egui/src/layers_keyboard_tests.rs | 197 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 12 ++ crates/ui-egui/src/menus.rs | 9 +- crates/ui-egui/src/perf_tests.rs | 64 ++++++- crates/ui-egui/src/right.rs | 29 ++- crates/ui-egui/src/shortcuts.rs | 59 ++++++ docs/mcp.md | 24 ++- docs/roadmap.md | 2 +- docs/target-app-parity.md | 28 +-- docs/ui-parity.md | 8 +- 26 files changed, 750 insertions(+), 98 deletions(-) create mode 100644 crates/engine/src/select_area_tests.rs create mode 100644 crates/engine/src/stroke_style_tests.rs create mode 100644 crates/ui-egui/src/layers_keyboard_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 09b5522..945b3b7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~173–310 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -17,9 +17,9 @@ start with [`docs/gaps.md`](docs/gaps.md). | Feature breadth | **~80%** (core editor ~91%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | | Ready for real work | **~49%** | estimated, same doc (written dimension weights and scores) | | Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | -| Mainstream practitioner | **~46%** | estimated: weekly areas 67.8% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | +| Mainstream practitioner | **~46%** | estimated: weekly areas 68.1% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~173–310 h** one agent | estimated | -| Remaining to full parity | **~321–618 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~320–615 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | ### Readiness by audience @@ -56,7 +56,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Dimension | Ready | Hours | Doc | |---|---|---|---| -| Features (depth) | 57% (core editor 68%) | 60–115 | [target-app-parity](docs/target-app-parity.md) | +| Features (depth) | 57% (core editor 68%) | 59–112 | [target-app-parity](docs/target-app-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | File formats | 55% (`.fig` read best effort, no write) | 8–15 | [file-format-parity](docs/file-format-parity.md) | | Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) | @@ -74,10 +74,10 @@ Figma's own languages on the landed localization layer (15–25 h). |---|---|---|---|---| | Canvas & navigation | 95% | 72% | 3–6 | [ui-parity](docs/ui-parity.md) | | Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) | -| Layers panel | 95% | 78% | 2–4 | [ui-parity](docs/ui-parity.md) | +| Layers panel | 98% | 82% | 1–2 | [ui-parity](docs/ui-parity.md) | | Shapes & vector | 97% | 68% | 1–3 | [target-app-parity](docs/target-app-parity.md) | | Paint | 85% | 66% | 5–9 | [target-app-parity](docs/target-app-parity.md) | -| Strokes | 90% | 72% | 1–3 | [target-app-parity](docs/target-app-parity.md) | +| Strokes | 95% | 75% | 1–2 | [target-app-parity](docs/target-app-parity.md) | | Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) | | Frames, groups, sections, masks | 95% | 75% | 2–4 | [target-app-parity](docs/target-app-parity.md) | | Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) | @@ -195,6 +195,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h | | 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h | | 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h | | 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 in the desktop app and through MCP; eight fixes: recovery copies of unsaved documents reopen after a crash or a forced quit (`file.recover`), Ctrl moves and deletes by word in text on Windows and Linux, scrubbed fields keep their value, typing on a field Tab reached edits it, Escape closes dialogs, linear gradients keep square bands, panel hints wrap, the window-close question and "Open in new window" are translated. Percentages and hours unchanged | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 64a27f7..2fb0e8e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -83,6 +83,13 @@ pub fn specs() -> Vec { ok() }), spec!("select.inverse", "Select inverse", "{}", false, select_inverse), + spec!( + "select.area", + "Select area", + "{x, y, width, height (page units; a marquee from x, y), add? (toggle like a Shift marquee), deep? (nested layers, like Cmd/Ctrl)} → {selection}; with no box: draw one from the keyboard in the app (arrows move it, Shift+arrows size it, Enter selects, Escape cancels)", + false, + select_area + ), spec!("select.children", "Select children", "{}", false, select_children), spec!("select.parent", "Select parent", "{}", false, select_parent), spec!("select.next", "Select next sibling", "{}", false, |s, _| select_sibling(s, 1)), @@ -688,6 +695,29 @@ fn select_inverse(s: &mut Session, _p: &Value) -> Result { Ok(json!({"selection": ids})) } +/// `select.area`: what a marquee from (x, y) over the box selects; no box asks the app for one. +fn select_area(s: &mut Session, p: &Value) -> Result { + let cmd = "select.area"; + let keys = ["x", "y", "width", "height"]; + if keys.iter().all(|k| p.get(*k).is_none()) { + s.request_ui("selectArea", json!({})); + return ok(); + } + let [x, y, w, h] = keys.map(|k| num(p, k)); + let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) else { return Err(bad(cmd, "pass x, y, width and height (numbers)")) }; + if ![x, y, w, h].iter().all(|v| v.is_finite() && v.abs() < 1e9) { + return Err(bad(cmd, "the box is out of range")); + } + let start = figcraft_geom::Point::new(x, y); + let r = figcraft_geom::Rect::from_points(start, figcraft_geom::Point::new(x + w, y + h)); + let add = boolean(p, "add") == Some(true); + let base = if add { s.selection() } else { Vec::new() }; + let within = s.marquee_within(start)?; + let sel = s.marquee_selection(r, within, boolean(p, "deep") == Some(true), &base, add)?; + s.set_selection(sel.clone()); + Ok(json!({"selection": sel})) +} + fn select_children(s: &mut Session, _p: &Value) -> Result { let doc = s.doc()?; let mut ids = Vec::new(); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 1b18282..ec5dd94 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -8,9 +8,12 @@ use serde_json::{Map, Value, json}; use crate::params::*; use crate::{Result, bad}; +/// The per-side stroke weight keys, in `StrokeProps::sides` order (top, right, bottom, left). +pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "strokeBottomWeight", "strokeLeftWeight"]; + /// Keys `node.set` understands (documented for agents). pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (parent-relative), width, height, rotation (degrees), \ -fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], \ +fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ strokeCap / startCap / endCap, strokeJoin, dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \ clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \ mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \ @@ -341,6 +344,17 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { doc.get_mut(id)?.stroke.sides = if v.is_null() { None } else { Some(four(v).ok_or_else(|| bad(cmd, "strokeSides: [t,r,b,l]"))?) } } + "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => { + let i = STROKE_SIDES.iter().position(|x| *x == k).unwrap_or(0); + let w = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); + let st = &mut doc.get_mut(id)?.stroke; + // One side set on its own turns on individual strokes, the others at the weight. + let mut sides = st.sides.unwrap_or([st.weight; 4]); + if let Some(slot) = sides.get_mut(i) { + *slot = w; + } + st.sides = Some(sides); + } "strokeCap" => { let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?; let n = doc.get_mut(id)?; @@ -719,7 +733,7 @@ pub fn override_group(k: &str) -> &'static str { match k { "fills" | "fill" => "fills", "strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "strokeCap" | "startCap" | "endCap" | "strokeJoin" | "strokeSides" - | "widthProfile" | "brush" | "strokeBrush" => "strokes", + | "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" | "widthProfile" | "brush" | "strokeBrush" => "strokes", "effects" => "effects", "opacity" | "blend" | "blendMode" => "opacity", "visible" => "visible", diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 75afeb4..a71e8b0 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -364,7 +364,7 @@ pub fn field_scope(doc: &Document, node: NodeId, field: &str) -> Option<&'static | "paddingBottom" | "paddingLeft" => "GAP", "cornerRadius" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "CORNER_RADIUS", "opacity" => "OPACITY", - "strokeWeight" => "STROKE_FLOAT", + "strokeWeight" | "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => "STROKE_FLOAT", "fontSize" => "FONT_SIZE", "lineHeight" => "LINE_HEIGHT", "letterSpacing" => "LETTER_SPACING", diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index d184e2c..1a2b1db 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -236,6 +236,13 @@ pub fn specs() -> Vec { }), spec!("view.nextRegion", "Focus next region", "{}", false, |s, _| region(s, 1)), spec!("view.previousRegion", "Focus previous region", "{}", false, |s, _| region(s, -1)), + spec!( + "view.contextMenu", + "Show context menu", + "{} the selection's context menu at its layer row (when the layers panel has the keyboard) or on the canvas; the menu takes the keyboard", + false, + |s, _| ui(s, "contextMenu") + ), spec!("view.themeLight", "Light theme", "{}", false, |s, _| { s.prefs.theme = "light".into(); ok() diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index 61217cb..ca247ab 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -208,6 +208,7 @@ pub static KEYS: &[Binding] = &[ k(View, "view.leftSidebar", &["Shift+Cmd+\\"]), k(View, "view.nextRegion", &["F6"]).label("Focus the next region (toolbar, layers, canvas, properties)").both(), k(View, "view.previousRegion", &["Shift+F6"]).label("Focus the previous region").both(), + k(View, "view.contextMenu", &["Shift+F10"]).label("Context menu of the selection (the focused layer row or the canvas)"), k(View, "layers.collapseAll", &["Alt+L"]), k(View, "doc.nextTab", &["Ctrl+Tab"]), k(View, "doc.previousTab", &["Ctrl+Shift+Tab"]), @@ -268,7 +269,8 @@ pub static KEYS: &[Binding] = &[ k(Selection, "object.frameSelection", &["Alt+Cmd+G"]), k(Selection, "object.toggleVisible", &["Shift+Cmd+H"]), k(Selection, "object.toggleLock", &["Shift+Cmd+L"]), - k(Selection, "object.rename", &["Cmd+R"]), + k(Selection, "object.rename", &["Cmd+R", "F2"]), + k(Selection, "select.area", &["F8"]).label("Select with a box from the keyboard (arrows move it, Shift+arrows size it, Enter selects)"), // Edit k(Edit, "edit.undo", &["Cmd+Z"]), k(Edit, "edit.redo", &["Shift+Cmd+Z"]), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5a0d1c5..08ec430 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1309,6 +1309,8 @@ mod section_contents_tests; #[cfg(test)] mod section_tests; #[cfg(test)] +mod select_area_tests; +#[cfg(test)] mod selection_behaviour_tests; #[cfg(test)] mod selection_depth_tests; @@ -1319,6 +1321,8 @@ mod shapebuilder_tests; #[cfg(test)] mod slot_tests; #[cfg(test)] +mod stroke_style_tests; +#[cfg(test)] mod style_tests; #[cfg(test)] mod svg_import_tests; diff --git a/crates/engine/src/select_area_tests.rs b/crates/engine/src/select_area_tests.rs new file mode 100644 index 0000000..8f1d4cf --- /dev/null +++ b/crates/engine/src/select_area_tests.rs @@ -0,0 +1,78 @@ +//! `select.area`: what a marquee over a box selects, without a pointer (keyboard box selection in +//! the app, agents over MCP): top-level layers, a frame only when fully covered, the children of +//! the frame the box starts in, Shift-like toggling, Cmd/Ctrl-like nested layers; no box asks the +//! app to draw one. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::NodeId; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn rect(s: &mut Session, x: f64, y: f64, parent: Option) -> NodeId { + id_of(&ex(s, "node.create", json!({"type": "rectangle", "parent": parent, "x": x, "y": y, "width": 20, "height": 20}))["id"]).unwrap() +} + +fn area(s: &mut Session, x: f64, y: f64, w: f64, h: f64, extra: Value) -> Vec { + let mut p = json!({"x": x, "y": y, "width": w, "height": h}); + if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) { + p.extend(e.clone()); + } + let out = ex(s, "select.area", p); + let listed: Vec = out["selection"].as_array().unwrap().iter().filter_map(id_of).collect(); + assert_eq!(listed, s.selection(), "the result lists the new selection"); + sorted(listed) +} + +fn sorted(mut v: Vec) -> Vec { + v.sort(); + v +} + +#[test] +fn a_box_selects_like_a_marquee() { + let mut s = Session::new(); + let frame = id_of(&ex(&mut s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 200}))["id"]).unwrap(); + let inner = rect(&mut s, 20.0, 20.0, Some(frame)); + let a = rect(&mut s, 0.0, 0.0, None); + let b = rect(&mut s, 100.0, 0.0, None); + // Top-level layers it touches; a frame only touched from outside isn't selected. + assert_eq!(area(&mut s, -10.0, -10.0, 340.0, 40.0, json!({})), sorted(vec![a, b])); + // A negative size draws the box the other way. + assert_eq!(area(&mut s, 130.0, 30.0, -140.0, -40.0, json!({})), sorted(vec![a, b])); + // Covering the frame selects it. + assert_eq!(area(&mut s, 290.0, -10.0, 220.0, 220.0, json!({})), vec![frame]); + // Starting inside the frame selects its children. + assert_eq!(area(&mut s, 305.0, 5.0, 60.0, 60.0, json!({})), vec![inner]); + // Deep: nested layers too. + assert_eq!(area(&mut s, -10.0, -10.0, 340.0, 40.0, json!({"deep": true})), sorted(vec![a, b, inner])); + // Add toggles: what was selected and is covered again comes out. + ex(&mut s, "select.set", json!({"ids": [a]})); + assert_eq!(area(&mut s, 90.0, -10.0, 40.0, 40.0, json!({"add": true})), sorted(vec![a, b])); + assert_eq!(area(&mut s, -10.0, -10.0, 40.0, 40.0, json!({"add": true})), vec![b]); + // An empty spot clears the selection (without add). + assert_eq!(area(&mut s, 1000.0, 1000.0, 10.0, 10.0, json!({})), Vec::::new()); +} + +#[test] +fn no_box_asks_the_app_for_one_and_bad_boxes_are_refused() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0, None); + ex(&mut s, "select.set", json!({"ids": [a]})); + s.ui_requests.clear(); + ex(&mut s, "select.area", json!({})); + assert!(s.ui_requests.iter().any(|r| r.kind == "selectArea")); + assert_eq!(s.selection(), vec![a], "nothing selected yet"); + for bad in [ + json!({"x": 0, "y": 0, "width": 10}), + json!({"x": 0, "y": 0, "width": "wide", "height": 1}), + json!({"x": 1e300, "y": 0, "width": 1, "height": 1}), + ] { + assert!(s.execute("select.area", &bad).is_err(), "{bad}"); + } + assert_eq!(s.selection(), vec![a]); +} diff --git a/crates/engine/src/stroke_style_tests.rs b/crates/engine/src/stroke_style_tests.rs new file mode 100644 index 0000000..d7409b6 --- /dev/null +++ b/crates/engine/src/stroke_style_tests.rs @@ -0,0 +1,77 @@ +//! Styles and variables on strokes: a colour style applied to a layer's strokes (linked, following +//! edits of the style, one undo step, detached), and stroke weights, each side on its own too, +//! bound to number variables that follow the frame's mode. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_color::Color; +use figcraft_doc::NodeId; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn node(s: &Session, id: NodeId) -> figcraft_doc::Node { + s.doc().unwrap().node(id).unwrap().clone() +} + +fn stroke_hex(s: &Session, id: NodeId) -> Option { + node(s, id).strokes.first().and_then(|p| p.solid_color()).map(Color::to_hex) +} + +#[test] +fn a_colour_style_on_strokes_links_follows_edits_and_detaches() { + let mut s = Session::new(); + let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"]).unwrap(); + let fill = node(&s, r).fills; + let st = ex(&mut s, "style.create", json!({"type": "paint", "name": "Brand/Line", "value": ["#ff0000"]})); + let st = st["id"].as_str().unwrap().to_string(); + ex(&mut s, "style.apply", json!({"ids": [r], "style": "Brand/Line", "target": "stroke"})); + assert_eq!(node(&s, r).styles.stroke.as_deref(), Some(st.as_str())); + assert!(node(&s, r).styles.fill.is_none()); + assert_eq!(node(&s, r).fills, fill, "the fill keeps its own paint"); + let red = stroke_hex(&s, r); + assert!(red.as_deref().is_some_and(|h| h.eq_ignore_ascii_case("FF0000")), "{red:?}"); + // One undo step takes the style off again. + ex(&mut s, "edit.undo", json!({})); + assert!(node(&s, r).styles.stroke.is_none()); + ex(&mut s, "edit.redo", json!({})); + ex(&mut s, "style.update", json!({"style": st, "value": ["#0000ff"]})); + let blue = stroke_hex(&s, r); + assert!(blue.as_deref().is_some_and(|h| h.eq_ignore_ascii_case("0000FF")), "linked strokes follow the style: {blue:?}"); + ex(&mut s, "style.detach", json!({"ids": [r], "which": "stroke"})); + assert!(node(&s, r).styles.stroke.is_none()); + assert_eq!(stroke_hex(&s, r), blue, "detaching keeps the colour"); +} + +#[test] +fn stroke_weights_and_each_side_bind_to_number_variables() { + let mut s = Session::new(); + let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 200}))["id"]).unwrap(); + let r = id_of(&ex(&mut s, "node.create", json!({"type": "rectangle", "parent": f, "x": 10, "y": 10, "width": 50, "height": 50}))["id"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokes": ["#000000"], "strokeWeight": 2}})); + let c = ex(&mut s, "variable.createCollection", json!({"name": "Borders", "modes": ["Thin", "Thick"]})); + let c = c["id"].as_str().unwrap().to_string(); + ex(&mut s, "variable.create", json!({"collection": c, "name": "border/top", "type": "number", "values": {"Thin": 1, "Thick": 6}})); + ex(&mut s, "variable.create", json!({"collection": c, "name": "border/all", "type": "number", "values": {"Thin": 3, "Thick": 5}})); + // One side: individual strokes turn on, the other sides keep the weight. + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "strokeTopWeight", "variable": "border/top"})); + assert_eq!(node(&s, r).stroke.sides, Some([1.0, 2.0, 2.0, 2.0])); + ex(&mut s, "variable.setMode", json!({"ids": [f], "collection": c, "mode": "Thick"})); + assert_eq!(node(&s, r).stroke.sides, Some([6.0, 2.0, 2.0, 2.0]), "the frame's mode picks the value"); + // The whole weight binds too; per-side keys set one side. + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "strokeWeight", "variable": "border/all"})); + assert_eq!(node(&s, r).stroke.weight, 5.0); + ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokeLeftWeight": 4}})); + assert_eq!(node(&s, r).stroke.sides.map(|x| x[3]), Some(4.0)); + assert!(s.execute("node.set", &json!({"ids": [r], "props": {"strokeRightWeight": "thick"}})).is_err()); + // Only stroke-weight variables are offered for these fields, and instances count them as + // stroke overrides. + let doc = s.doc().unwrap(); + for field in crate::cmd::props::STROKE_SIDES.into_iter().chain(["strokeWeight"]) { + assert_eq!(crate::cmd::variables::field_scope(doc, r, field), Some("STROKE_FLOAT"), "{field}"); + assert_eq!(crate::cmd::props::override_group(field), "strokes", "{field}"); + } +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 28ac26c..77267d0 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -717,6 +717,41 @@ pub(crate) fn scale_factor(handle: Handle, q: Point, w0: f64, h0: f64, anchor: ( } impl Session { + /// What a marquee starting at page point `p` selects inside: the top-level frame it starts in + /// (its children), or the focus view's frame. + pub(crate) fn marquee_within(&self, p: Point) -> Result> { + let (doc, page) = (self.doc()?, self.page()?); + let within = frame_at(doc, page, p, &[]).filter(|f| doc.node(*f).is_some_and(|n| n.parent == Some(page))); + let within = within.filter(|f| doc.world_bounds(*f).is_some_and(|b| b.contains(p))); + Ok(self.ds()?.focused().or(within)) + } + + /// The selection a marquee over page rect `r` makes from `base` (what was selected before): + /// the layers it touches (`deep`: nested ones too); `additive` (Shift) toggles them. + pub(crate) fn marquee_selection(&self, r: Rect, within: Option, deep: bool, base: &[NodeId], additive: bool) -> Result> { + let (doc, page) = (self.doc()?, self.page()?); + let mut hits = + if deep { figcraft_doc::geometry::marquee_deep(doc, page, r) } else { figcraft_doc::geometry::marquee_select(doc, page, r, within) }; + if let Some(f) = self.ds()?.focused() { + hits.retain(|h| *h != f && doc.is_ancestor(f, *h)); + } + // A marquee over a frame from outside selects the frame only when it covers it fully. + if within.is_none() && !deep { + hits.retain(|h| { + let full = doc.world_bounds(*h).is_some_and(|b| r.contains(b.origin()) && r.contains(Point::new(b.x1, b.y1))); + full || !doc.node(*h).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) + }); + } + // Shift toggles: what was selected and is touched again comes out. + let mut sel: Vec = base.iter().copied().filter(|b| !(additive && hits.contains(b))).collect(); + for h in hits { + if !base.contains(&h) && !sel.contains(&h) { + sel.push(h); + } + } + Ok(sel) + } + pub fn set_tool(&mut self, t: ToolId) { if self.tool.tool == ToolId::Pen && t != ToolId::Pen { let _ = self.pen_finish(); @@ -939,11 +974,7 @@ impl Session { self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default }; } None => { - // Marquee; starting inside a top-level frame selects its children. - let within = frame_at(&doc, page, ev.p(), &[]).filter(|f| doc.node(*f).is_some_and(|n| n.parent == Some(page))); - let within = within.filter(|f| doc.world_bounds(*f).is_some_and(|b| b.contains(ev.p()))); - // The focus view selects only inside its frame. - let within = self.ds()?.focused().or(within); + let within = self.marquee_within(ev.p())?; if !ev.shift { self.set_selection(vec![]); } @@ -960,31 +991,8 @@ impl Session { Gesture::Marquee { start, additive, base, within } => { let r = Rect::from_points(*start, ev.p()); self.tool.overlay.marquee = Some([r.x0, r.y0, r.x1, r.y1]); - let doc = self.doc()?.clone(); - let page = self.page()?; // Cmd/Ctrl: the nested layers it touches. - let mut hits = if ev.cmd { - figcraft_doc::geometry::marquee_deep(&doc, page, r) - } else { - figcraft_doc::geometry::marquee_select(&doc, page, r, *within) - }; - if let Some(f) = self.ds()?.focused() { - hits.retain(|h| *h != f && doc.is_ancestor(f, *h)); - } - // A marquee over a frame from outside selects the frame only when it covers it fully. - if within.is_none() && !ev.cmd { - hits.retain(|h| { - let full = doc.world_bounds(*h).is_some_and(|b| r.contains(b.origin()) && r.contains(Point::new(b.x1, b.y1))); - full || !doc.node(*h).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) - }); - } - // Shift toggles: what was selected and is touched again comes out. - let mut sel: Vec = base.iter().copied().filter(|b| !(*additive && hits.contains(b))).collect(); - for h in hits { - if !base.contains(&h) && !sel.contains(&h) { - sel.push(h); - } - } + let sel = self.marquee_selection(r, *within, ev.cmd, base, *additive)?; self.set_selection(sel); Ok(()) } diff --git a/crates/format/src/rest/variables.rs b/crates/format/src/rest/variables.rs index 41ee4d4..af363c4 100644 --- a/crates/format/src/rest/variables.rs +++ b/crates/format/src/rest/variables.rs @@ -326,8 +326,9 @@ fn node_binding<'v>(k: &str, v: &'v Value, n: &Node, out: &mut Vec<(String, &'v } "itemSpacing" => push("gap", alias(v)), "counterAxisSpacing" => push("crossGap", alias(v)), - "characters" | "visible" | "opacity" | "strokeWeight" | "paddingLeft" | "paddingRight" | "paddingTop" | "paddingBottom" | "topLeftRadius" - | "topRightRadius" | "bottomLeftRadius" | "bottomRightRadius" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => push(k, alias(v)), + "characters" | "visible" | "opacity" | "strokeWeight" | "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" + | "strokeLeftWeight" | "paddingLeft" | "paddingRight" | "paddingTop" | "paddingBottom" | "topLeftRadius" | "topRightRadius" + | "bottomLeftRadius" | "bottomRightRadius" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => push(k, alias(v)), "rectangleCornerRadii" => { for (key, f) in [ ("RECTANGLE_TOP_LEFT_CORNER_RADIUS", "topLeftRadius"), diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 166b85a..905d782 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -979,6 +979,8 @@ name = "English" "Select points to edit their position, radius and mirroring" = "Select points to edit their position, radius and mirroring" "Select slot" = "Select slot" "Selection" = "Selection" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows size it, Enter selects, Escape cancels" = "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows size it, Enter selects, Escape cancels" +"Selection box: arrows move it, Shift+arrows size it, Enter selects, Escape cancels" = "Selection box: arrows move it, Shift+arrows size it, Enter selects, Escape cancels" "Send backward" = "Send backward" "Send to back" = "Send to back" "Server address" = "Server address" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index a7b29ee..c368b98 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -980,6 +980,8 @@ status = "draft — needs native review" "Select points to edit their position, radius and mirroring" = "Selecciona puntos para editar su posición, radio y reflejo" "Select slot" = "Seleccionar ranura" "Selection" = "Selección" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows size it, Enter selects, Escape cancels" = "Cuadro de selección, {w} por {h} en {x}, {y}: las flechas lo mueven, Mayús+flechas cambian su tamaño, Intro selecciona, Esc cancela" +"Selection box: arrows move it, Shift+arrows size it, Enter selects, Escape cancels" = "Cuadro de selección: las flechas lo mueven, Mayús+flechas cambian su tamaño, Intro selecciona, Esc cancela" "Send backward" = "Enviar atrás" "Send to back" = "Enviar al fondo" "Server address" = "Dirección del servidor" @@ -1598,6 +1600,7 @@ status = "draft — needs native review" "section.toggleContents" = "Ocultar/mostrar contenido de la sección" "select.add" = "Añadir a la selección" "select.all" = "Seleccionar todo" +"select.area" = "Seleccionar área" "select.byName" = "Seleccionar capas por nombre" "select.children" = "Seleccionar hijos" "select.inverse" = "Invertir selección" @@ -1779,6 +1782,7 @@ status = "draft — needs native review" "version.restore" = "Restaurar versión" "view.assets" = "Panel de recursos" "view.comments" = "Comentarios" +"view.contextMenu" = "Mostrar menú contextual" "view.design" = "Panel de diseño" "view.designPrototype" = "Alternar entre Diseño y Prototipo" "view.devMode" = "Modo desarrollo" diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index cd0069c..162dfd7 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -327,24 +327,9 @@ fn keys(app: &mut FigApp, ctx: &Context) { return; } // F6 / Shift+F6 (and the commands they run). - let platform = crate::shortcuts::platform(); - let region_key = ctx.input(|i| { - i.events.iter().find_map(|e| match e { - Event::Key { key, pressed: true, modifiers, .. } => { - keymap::lookup(platform, keymap::Context::Canvas, key.name(), crate::shortcuts::mods(*modifiers)) - .and_then(|b| match b.id { - NEXT_REGION => Some(1), - PREVIOUS_REGION => Some(-1), - _ => None, - }) - .map(|step| (step, *key, *modifiers)) - } - _ => None, - }) - }); - if let Some((step, key, m)) = region_key { + if let Some((id, key, m)) = binding_pressed(ctx, &[NEXT_REGION, PREVIOUS_REGION]) { ctx.input_mut(|i| i.consume_key(m, key)); - app.ui.nav.step(step); + app.ui.nav.step(if id == NEXT_REGION { 1 } else { -1 }); } if let Some(mv) = app.ui.nav.pending.take() { let to = match mv { @@ -418,6 +403,12 @@ fn keys(app: &mut FigApp, ctx: &Context) { } } Region::Layers if in_region && app.ui.nav.last.row_focused => { + // The canvas shortcuts are off while a row has the keyboard: rename (F2, Cmd/Ctrl+R) + // and the context menu (Shift+F10) work on its layer here. + if let Some((id, key, m)) = binding_pressed(ctx, &["object.rename", "view.contextMenu"]) { + ctx.input_mut(|i| i.consume_key(m, key)); + crate::shortcuts::run_binding(app, id); + } for k in arrows { let shift = matches!(k, Key::ArrowUp | Key::ArrowDown) && pressed(k, Modifiers::SHIFT); if (pressed(k, Modifiers::NONE) || shift) && crate::layers::key_nav(app, k, shift) { @@ -429,8 +420,10 @@ fn keys(app: &mut FigApp, ctx: &Context) { } _ => {} } - // Escape gives the keyboard back to the canvas (a popup it closes keeps it where it was). + // Escape gives the keyboard back to the canvas (a popup it closes keeps it where it was; a layer + // rename it cancels gives it back to the row). if region != Region::Canvas + && app.ui.rename.is_none() && pressed(Key::Escape, Modifiers::NONE) && !egui::Popup::is_any_open(ctx) && app.ui.nav.opener.is_none() @@ -455,6 +448,63 @@ fn keys(app: &mut FigApp, ctx: &Context) { } } +/// The first of the commands `ids` a key pressed this frame is bound to (canvas keys), with the key. +fn binding_pressed(ctx: &Context, ids: &[&str]) -> Option<(&'static str, Key, Modifiers)> { + let platform = crate::shortcuts::platform(); + ctx.input(|i| { + i.events.iter().find_map(|e| match e { + Event::Key { key, pressed: true, modifiers, .. } => { + keymap::lookup(platform, keymap::Context::Canvas, key.name(), crate::shortcuts::mods(*modifiers)) + .filter(|b| ids.contains(&b.id)) + .map(|b| (b.id, *key, *modifiers)) + } + _ => None, + }) + }) +} + +/// A layer rename from the keyboard ended: the selected layer's row takes the keyboard again. +pub fn refocus_row(nav: &mut Nav) { + if nav.keyboard { + nav.region = Region::Layers; + nav.refocus = 3; + } +} + +/// Open the context menu from the keyboard (Shift+F10, `view.contextMenu`): for the selection, at +/// its layer row when the layers panel has the keyboard, else on the canvas below the selection +/// (the middle of the canvas with nothing selected). The menu takes the keyboard (keyboard mode), +/// and gives it back to the row or the canvas when it closes. Call after the panels drew. +pub fn context_menu(app: &mut FigApp, ctx: &Context) { + let nav = &app.ui.nav; + let rect_of = |id: Id| ctx.viewport(|v| v.this_pass.widgets.get(id).or_else(|| v.prev_pass.widgets.get(id)).map(|w| w.rect)); + let row = (app.ui.layers_focus || nav.region == Region::Layers) + .then(|| Region::Layers.of(&nav.next.preferred).or(*Region::Layers.of(&nav.last.preferred))) + .flatten() + .and_then(|id| Some((id, rect_of(id)?))); + let (opener, pos, region) = match row { + Some((id, r)) => (Some(id), egui::pos2(r.min.x + 40.0, r.max.y), Region::Layers), + None => { + let canvas = app.canvas.rect; + let below = app.session.ds().ok().and_then(|ds| { + let b = ds.selection.iter().filter_map(|i| ds.doc.world_bounds(*i)).reduce(|a, b| a.union(b))?; + Some(egui::pos2(app.to_screen(b.x0, b.y1).x, app.to_screen(b.x0, b.y1).y + 8.0)) + }); + let pos = below.filter(|p| canvas.contains(*p)).unwrap_or(canvas.center()); + (nav.next.canvas.or(nav.last.canvas), pos, Region::Canvas) + } + }; + app.ui.context_menu = Some((pos, app.session.selection())); + let nav = &mut app.ui.nav; + nav.keyboard = true; + nav.region = region; + nav.opener = None; + if let Some(id) = opener { + ctx.memory_mut(|m| m.request_focus(id)); + } + ctx.request_repaint(); +} + /// After everything drew: the layer row the arrows moved to takes the keyboard, and the focus ring. pub fn end(app: &mut FigApp, ctx: &Context) { // A popup registered before it closed later in the frame (Escape) isn't open any more. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7dde290..f46410d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -145,7 +145,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let ctx = ui.ctx().clone(); let ppp = ctx.pixels_per_point(); let resp = ui.interact(rect, ui.id().with("canvas"), Sense::click_and_drag()); - crate::a11y::canvas(&mut app.ui.nav, &resp, || describe(&app.session)); + let key_box = app.ui.key_box; + crate::a11y::canvas(&mut app.ui.nav, &resp, || key_box.map_or_else(|| describe(&app.session), describe_box)); if let Ok(ds) = app.session.ds_mut() && ((ds.view.vw - f64::from(rect.width())).abs() > 0.5 || (ds.view.vh - f64::from(rect.height())).abs() > 0.5) { @@ -270,6 +271,16 @@ pub fn describe(s: &figcraft_engine::Session) -> String { } } +/// What the canvas says while a keyboard selection box shows: its size and place, and the keys. +fn describe_box([x0, y0, x1, y1]: [f64; 4]) -> String { + let n = crate::widgets::fmt_num; + let (w, h, x, y) = (n((x1 - x0).abs()), n((y1 - y0).abs()), n(x0.min(x1)), n(y0.min(y1))); + trf( + "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows size it, Enter selects, Escape cancels", + &[("w", &w), ("h", &h), ("x", &x), ("y", &y)], + ) +} + /// Pixel preview's resolution (rendered pixels per page pixel) when it's on and zoomed in past it. fn pixel_preview(ds: &figcraft_engine::DocState) -> Option { let k = f64::from(ds.view.pixel_scale.clamp(1, 2)); @@ -378,6 +389,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { // A press on the canvas gives the keyboard back to it (arrows nudge again). if resp.hovered() && ctx.input(|i| i.pointer.any_pressed()) { app.ui.layers_focus = false; + app.ui.key_box = None; } // Zoom (pinch, Cmd/Ctrl+scroll) about the pointer; plain scroll pans. if resp.hovered() { @@ -936,7 +948,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ // Smart guides and measurements (their labels kept clear of the size label). crate::measure_overlay::draw(app, painter, t, size_label); // Marquee. - if let Some([x0, y0, x1, y1]) = ov.marquee { + if let Some([x0, y0, x1, y1]) = ov.marquee.or(app.ui.key_box) { let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1)); painter.rect_filled(r, CornerRadius::ZERO, ACCENT.gamma_multiply(0.1)); painter.rect_stroke(r, CornerRadius::ZERO, stroke(ACCENT, 1.0), StrokeKind::Inside); diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index b3fbdb5..d44e619 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -589,6 +589,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // go on to the next / previous row. let mut rename_row: Option<(usize, NodeId, usize)> = None; let mut rename_next: Option = None; + let renaming = app.ui.rename.is_some(); if let Some(rn) = app.ui.rename { match rows.iter().position(|r| r.id == rn.id) { None => app.ui.rename = None, @@ -906,6 +907,9 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if let Some(id) = rename_next { rename_layer(app, id); } + if renaming && app.ui.rename.is_none() { + a11y::refocus_row(&mut app.ui.nav); + } if let Some((id, m)) = clicked { app.ui.layers_focus = true; let order: Vec = rows.iter().map(|r| r.id).collect(); diff --git a/crates/ui-egui/src/layers_keyboard_tests.rs b/crates/ui-egui/src/layers_keyboard_tests.rs new file mode 100644 index 0000000..e1a2ed7 --- /dev/null +++ b/crates/ui-egui/src/layers_keyboard_tests.rs @@ -0,0 +1,197 @@ +//! The keyboard alone, driven through egui headless: F2 renames the selected layer (from the +//! canvas, a clicked row or a row with the keyboard, which gets it back afterwards), Shift+F10 +//! opens the context menu at the focused row or below the canvas selection and the menu takes the +//! keyboard, and F8 draws a selection box the arrows move and size and Enter selects with. + +use egui::{Event, Key, Modifiers}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::a11y::{Region, focused_name}; +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn press(u: &mut Ui, key: Key, modifiers: Modifiers) { + u.frame(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ]); +} + +fn settle(u: &mut Ui) { + for _ in 0..3 { + u.frame(vec![]); + } +} + +fn name(u: &Ui, id: NodeId) -> String { + u.app.session.doc().unwrap().node(id).unwrap().name.clone() +} + +fn renaming(u: &Ui) -> Option { + u.app.ui.rename.map(|r| r.id) +} + +/// Two rectangles, "Rect A" and "Rect B" (listed above A: it's on top), A selected. +fn setup() -> (Ui, NodeId, NodeId) { + let mut s = Session::new(); + let a = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap()); + let b = id(s.execute("shape.rectangle", &json!({"x": 100, "y": 30, "width": 20, "height": 20})).unwrap()); + s.execute("object.rename", &json!({"ids": [a], "name": "Rect A"})).unwrap(); + s.execute("object.rename", &json!({"ids": [b], "name": "Rect B"})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + (u, a, b) +} + +/// F6 to the layers panel, on the selected layer's row. +fn to_layers(u: &mut Ui) { + for _ in 0..4 { + press(u, Key::F6, Modifiers::NONE); + if u.app.ui.nav.region == Region::Layers { + return; + } + } + panic!("F6 never reached the layers panel"); +} + +#[test] +fn f2_renames_from_the_canvas_and_from_a_row_with_the_keyboard() { + let (mut u, a, _) = setup(); + press(&mut u, Key::F2, Modifiers::NONE); + assert_eq!(renaming(&u), Some(a), "F2 on the canvas renames the selected layer"); + u.frame(vec![]); + u.frame(vec![Event::Text("Alpha".into())]); + press(&mut u, Key::Enter, Modifiers::NONE); + assert_eq!(name(&u, a), "Alpha"); + assert_eq!(renaming(&u), None); + // From a row with the keyboard (the canvas shortcuts are off there): the row gets it back. + to_layers(&mut u); + assert_eq!(focused_name(&u.ctx), "Alpha"); + press(&mut u, Key::F2, Modifiers::NONE); + assert_eq!(renaming(&u), Some(a)); + u.frame(vec![]); + u.frame(vec![Event::Text("One".into())]); + press(&mut u, Key::Enter, Modifiers::NONE); + settle(&mut u); + assert_eq!(name(&u, a), "One"); + assert_eq!((u.app.ui.nav.region, focused_name(&u.ctx)), (Region::Layers, "One".to_string()), "the row has the keyboard again"); + // Escape cancels a rename and stays on the row (it doesn't jump to the canvas). + press(&mut u, Key::F2, Modifiers::NONE); + u.frame(vec![]); + u.frame(vec![Event::Text("Dropped".into())]); + press(&mut u, Key::Escape, Modifiers::NONE); + settle(&mut u); + assert_eq!(name(&u, a), "One"); + assert_eq!((u.app.ui.nav.region, focused_name(&u.ctx)), (Region::Layers, "One".to_string())); +} + +#[test] +fn shift_f10_opens_the_context_menu_at_the_row_or_the_canvas_selection() { + let (mut u, a, _) = setup(); + to_layers(&mut u); + press(&mut u, Key::F10, Modifiers::SHIFT); + settle(&mut u); + let (pos, ids) = u.app.ui.context_menu.clone().expect("the menu opens"); + assert_eq!(ids, vec![a]); + assert!(pos.x < u.app.canvas.rect.min.x, "at the row in the layers panel: {pos:?}"); + let item = focused_name(&u.ctx); + assert!(!item.is_empty() && item != "Rect A", "the menu has the keyboard: {item:?}"); + press(&mut u, Key::Escape, Modifiers::NONE); + settle(&mut u); + assert!(u.app.ui.context_menu.is_none()); + assert_eq!(focused_name(&u.ctx), "Rect A", "closing gives the keyboard back to the row"); + // On the canvas: below the selection, and the menu takes the keyboard. + press(&mut u, Key::Escape, Modifiers::NONE); + assert_eq!(u.app.ui.nav.region, Region::Canvas); + press(&mut u, Key::F10, Modifiers::SHIFT); + settle(&mut u); + let (pos, ids) = u.app.ui.context_menu.clone().expect("the menu opens on the canvas"); + assert_eq!(ids, vec![a]); + let bottom = u.app.to_screen(0.0, 30.0).y; + assert!(u.app.canvas.rect.contains(pos) && pos.y > bottom, "below the selection: {pos:?}"); + assert_ne!(focused_name(&u.ctx), "Canvas"); + press(&mut u, Key::Escape, Modifiers::NONE); + settle(&mut u); + assert!(u.app.ui.context_menu.is_none()); + assert_eq!(focused_name(&u.ctx), "Canvas"); + // After a click on a row (no keyboard mode), at that row too. + let p = u.row_at(a); + u.button(p, true); + u.button(p, false); + press(&mut u, Key::F10, Modifiers::SHIFT); + settle(&mut u); + let (pos, _) = u.app.ui.context_menu.clone().expect("the menu opens"); + assert!(pos.x < u.app.canvas.rect.min.x && (pos.y - p.y).abs() < 40.0, "{pos:?} near {p:?}"); +} + +#[test] +fn f8_draws_a_selection_box_the_arrows_move_and_size() { + let (mut u, a, b) = setup(); + press(&mut u, Key::F8, Modifiers::NONE); + let [x0, y0, x1, y1] = u.app.ui.key_box.expect("a box around the selection"); + assert!(x0 < 0.0 && y0 < 0.0 && x1 > 40.0 && y1 > 30.0 && x1 < 100.0, "{x0} {y0} {x1} {y1}"); + // Screen readers hear the box and the keys. + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let canvas = nodes.iter().find(|(_, n)| n.label() == Some("Canvas")).map(|(_, n)| n.description().unwrap_or_default().to_string()); + assert!(canvas.as_deref().is_some_and(|d| d.starts_with("Selection box")), "{canvas:?}"); + // Shift+arrows size it from its far corner until it reaches B. + for _ in 0..200 { + if u.app.ui.key_box.is_some_and(|b| b[2] > 125.0) { + break; + } + press(&mut u, Key::ArrowRight, Modifiers::SHIFT); + } + for _ in 0..200 { + if u.app.ui.key_box.is_some_and(|b| b[3] > 55.0) { + break; + } + press(&mut u, Key::ArrowDown, Modifiers::SHIFT); + } + assert_eq!(u.app.ui.key_box.map(|b| [b[0], b[1]]), Some([x0, y0]), "the start stays"); + assert_eq!(u.app.session.selection(), vec![a], "nothing selected before Enter"); + press(&mut u, Key::Enter, Modifiers::NONE); + let mut sel = u.app.session.selection(); + sel.sort(); + assert_eq!(sel, { + let mut v = vec![a, b]; + v.sort(); + v + }); + assert!(u.app.ui.key_box.is_none()); + // Plain arrows move the whole box; Escape drops it and keeps the selection. + press(&mut u, Key::F8, Modifiers::NONE); + let before = u.app.ui.key_box.unwrap(); + press(&mut u, Key::ArrowRight, Modifiers::NONE); + let after = u.app.ui.key_box.unwrap(); + assert!(after[0] > before[0] && (after[0] - before[0] - (after[2] - before[2])).abs() < 1e-9); + assert_eq!(u.app.doc_x(a), 0.0, "the arrows don't nudge layers while the box shows"); + press(&mut u, Key::Escape, Modifiers::NONE); + assert!(u.app.ui.key_box.is_none()); + assert_eq!(u.app.session.selection().len(), 2); + // With nothing selected it starts in the middle of the view. + u.app.run("select.none", json!({})); + press(&mut u, Key::F8, Modifiers::NONE); + let [x0, y0, x1, y1] = u.app.ui.key_box.unwrap(); + let c = u.app.to_page(u.app.canvas.rect.center()); + assert!(((x0 + x1) / 2.0 - c.x).abs() < 1e-6 && ((y0 + y1) / 2.0 - c.y).abs() < 1e-6); + // A press on the canvas drops it. + let p = u.app.canvas.rect.center(); + u.button(p, true); + u.button(p, false); + assert!(u.app.ui.key_box.is_none()); +} + +impl crate::FigApp { + fn doc_x(&self, id: NodeId) -> f64 { + self.session.doc().unwrap().node(id).unwrap().transform.translation().x + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6d99a9d..fa6cce6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -77,6 +77,8 @@ mod idle_tests; #[cfg(test)] mod layers_drag_tests; #[cfg(test)] +mod layers_keyboard_tests; +#[cfg(test)] mod layers_keys_tests; #[cfg(test)] mod layers_rename_tests; @@ -244,6 +246,12 @@ pub struct UiState { pub preview: Option, pub picker: Option, pub context_menu: Option<(egui::Pos2, Vec)>, + /// The context menu was asked for from the keyboard (`view.contextMenu`): it opens at the + /// focused layer row or the canvas selection and takes the keyboard. + pub key_menu: bool, + /// A selection box drawn from the keyboard (`select.area`), page `[x0, y0, x1, y1]` with the + /// marquee's start at `x0, y0`. + pub key_box: Option<[f64; 4]>, /// Layers panel search: open, the filter text, the shift-click range anchor and the selection /// the tree last revealed (ancestors expand and the row scrolls into view when it changes). pub layer_search: bool, @@ -339,6 +347,8 @@ impl Default for UiState { preview: None, picker: None, context_menu: None, + key_menu: false, + key_box: None, layer_search: false, layer_filter: String::new(), layer_anchor: None, @@ -563,6 +573,8 @@ impl FigApp { "preview.close" => self.ui.preview = None, "open.help" => self.services.open_url("https://getartcraft.com/apps/figcraft"), "layers.collapseAll" => self.ui.expanded.clear(), + "contextMenu" => self.ui.key_menu = true, + "selectArea" => self.start_key_box(), "layers.rename" => { let ids: Vec = r.data.get("ids").and_then(Value::as_array).into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 61e32a8..6548e44 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -430,8 +430,15 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { app.ui.main_menu = false; } } - // Context menu. + // Context menu (from the keyboard: at the focused layer row or the canvas selection, taking the + // keyboard from the control with it). + if std::mem::take(&mut app.ui.key_menu) { + a11y::context_menu(app, ctx); + } if let Some((pos, ids)) = app.ui.context_menu.clone() { + if let Some(f) = ctx.memory(|m| m.focused()) { + a11y::popup(&mut app.ui.nav, f, Id::new("ctx_menu")); + } let mut close = false; let area = egui::Area::new(Id::new("ctx_menu")).fixed_pos(pos).order(egui::Order::Foreground).constrain(true).show(ctx, |ui| { menu_frame(ui, &t, |ui| { diff --git a/crates/ui-egui/src/perf_tests.rs b/crates/ui-egui/src/perf_tests.rs index 152cad0..f8f39e9 100644 --- a/crates/ui-egui/src/perf_tests.rs +++ b/crates/ui-egui/src/perf_tests.rs @@ -7,7 +7,7 @@ use std::collections::BTreeSet; use std::sync::Arc; use std::time::Instant; -use egui::{Event, Pos2, RawInput, Rect, vec2}; +use egui::{Event, Key, Modifiers, Pos2, RawInput, Rect, vec2}; use figcraft_doc::{Document, NodeId}; use figcraft_engine::Session; use serde_json::json; @@ -104,12 +104,74 @@ fn report(layers: usize) { b.app.ui.layer_filter = "item".into(); b.frame(vec![]); rows.push(("idle frame, layers filtered by name", b.mean(30, |_| vec![]))); + b.app.ui.layer_search = false; + b.app.ui.layer_filter.clear(); + // Long lists from the keyboard: every layer expanded, the arrows walking the rows, a layer at + // the bottom of the list revealed, everything collapsed. + let open: Vec = b.app.session.doc().unwrap().nodes().filter(|n| !n.children.is_empty()).map(|n| n.id).collect(); + b.app.ui.expanded.extend(open); + rows.push(("first frame, every layer expanded", b.frame(vec![]))); + b.app.ui.layers_focus = true; + let top = b.app.session.doc().ok().and_then(|d| d.node(page)?.children.last().copied()); + b.app.session.set_selection(top.into_iter().collect()); + b.frame(vec![]); + rows.push(("arrow key down the expanded layers", b.mean(30, |_| key(Key::ArrowDown, Modifiers::NONE)))); + b.app.ui.expanded.clear(); + b.frame(vec![]); + b.app.session.set_selection(bottom_layer(&b.app.session).into_iter().collect()); + rows.push(("reveal a layer at the bottom of the list", b.frame(vec![]))); + b.app.run("layers.collapseAll", json!({})); + rows.push(("collapse all layers", b.frame(vec![]))); println!("UI frame ms — {layers} layers ({})", if cfg!(debug_assertions) { "debug" } else { "release" }); for (name, v) in rows { println!("| {name:<44} | {v:>8.2} |"); } } +/// A key press and release. +fn key(key: Key, modifiers: Modifiers) -> Vec { + vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ] +} + +/// The first screen's header title: the layers panel lists the top of the stack first, so it is +/// near the bottom of the list. +fn bottom_layer(s: &Session) -> Option { + let doc = s.doc().ok()?; + let screen = *doc.node(s.page().ok()?)?.children.get(1)?; + let header = *doc.node(screen)?.children.first()?; + doc.node(header)?.children.first().copied() +} + +#[test] +fn a_layer_revealed_at_the_bottom_of_10k_layers_gets_the_keyboard_on_its_row() { + let mut b = Bench::new(10_000); + let title = bottom_layer(&b.app.session).unwrap(); + b.app.ui.expanded.clear(); + b.app.session.set_selection(vec![title]); + // The list scrolls there (animated over a few frames). + for _ in 0..40 { + b.frame(vec![]); + } + let doc = b.app.session.doc().unwrap(); + assert!(doc.ancestors(title).iter().filter(|a| doc.node(**a).is_some_and(|n| !n.is_page())).all(|a| b.app.ui.expanded.contains(a))); + // F6 to the layers panel lands on the row, which is only drawn when it was scrolled into view. + b.frame(key(Key::F6, Modifiers::SHIFT)); + b.frame(vec![]); + assert_eq!(crate::a11y::focused_name(&b.ctx), "Title"); + // The arrows walk on from it and the keyboard follows. + b.frame(key(Key::ArrowUp, Modifiers::NONE)); + for _ in 0..3 { + b.frame(vec![]); + } + let sel = b.app.session.selection(); + assert!(sel.len() == 1 && sel != vec![title], "the row above is selected"); + let above = b.app.session.doc().unwrap().node(sel[0]).unwrap().name.clone(); + assert_eq!(crate::a11y::focused_name(&b.ctx), above); +} + /// The synthetic design system with `icons` icon components (plus its component sets) and /// `instances` instances using it. fn design_system(icons: usize, instances: usize) -> (Document, figcraft_engine::synthetic_ds::DesignSystem, Vec) { diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 2ca214f..0b688c7 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -1100,23 +1100,20 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: ui.data_mut(|d| d.insert_temp(adv_id, !open)); } }); - // Per-side weights. + // Per-side weights, two to a row (like the corner radii) so a bound variable's name fits: + // each side binds to a number variable on its own. if let Some(sides) = common(doc, sel, |n| n.stroke.sides).flatten() { - let quarter = (ui.available_width() - PAD * 2.0 - 3.0 * 6.0) / 4.0; - row(ui, 32.0, |ui| { - ui.spacing_mut().item_spacing.x = 6.0; - for (k, label) in ["T", "R", "B", "L"].iter().enumerate() { - let v = sides.get(k).copied(); - let e = widgets::number_field(ui, Id::new(("stroke_side", k)), Lead::Text(label), v, "", quarter, 1.0); - app.field(&ids, "strokeSides", e, |x| { - let mut n = sides; - if let Some(slot) = n.get_mut(k) { - *slot = x.max(0.0); - } - json!(n) - }); - } - }); + let col = widgets::col_width(ui.available_width()); + let fields = ["T", "R", "B", "L"].into_iter().zip(figcraft_engine::cmd::props::STROKE_SIDES); + for first in [0, 2] { + row(ui, 32.0, |ui| { + for (k, (label, field)) in fields.clone().enumerate().skip(first).take(2) { + let v = sides.get(k).copied(); + let e = crate::variable_ui::number_field(app, ui, &ids, field, Id::new(("stroke_side", k)), Lead::Text(label), v, "", col); + app.field(&ids, field, e, |x| json!(x.max(0.0))); + } + }); + } } if ui.data(|d| d.get_temp::(Id::new("stroke_adv"))).unwrap_or(false) { stroke_settings(app, ui, doc, sel); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 12865e8..397cf69 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -121,6 +121,9 @@ pub fn run_binding(app: &mut FigApp, id: &str) { } fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, now: f64, ctx: &Context) -> bool { + if key_box(app, key, m) { + return true; + } // A gradient open in the colour picker: Delete removes its selected stop. if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && app.ui.picker.is_some() && crate::handles::remove_selected_stop(app, ctx) { return true; @@ -179,6 +182,62 @@ fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, now: f64, c false } +/// Keys while a selection box drawn from the keyboard shows (`select.area`, F8): the arrows move +/// it (with Shift they move its far corner, sizing it) by 10 screen pixels, Enter selects what it +/// covers like a marquee (Shift toggles, Cmd/Ctrl takes nested layers), Escape drops it. +fn key_box(app: &mut FigApp, key: Key, m: Modifiers) -> bool { + let Some(mut b) = app.ui.key_box else { return false }; + let step = 10.0 / app.session.ds().map_or(1.0, |d| d.view.zoom.max(1e-6)); + let (dx, dy) = match key { + Key::ArrowLeft => (-step, 0.0), + Key::ArrowRight => (step, 0.0), + Key::ArrowUp => (0.0, -step), + Key::ArrowDown => (0.0, step), + _ => (0.0, 0.0), + }; + if (dx, dy) != (0.0, 0.0) { + if !m.shift { + b[0] += dx; + b[1] += dy; + } + b[2] += dx; + b[3] += dy; + app.ui.key_box = Some(b); + return true; + } + match key { + Key::Enter => { + app.ui.key_box = None; + let [x0, y0, x1, y1] = b; + app.run("select.area", json!({"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0, "add": m.shift, "deep": m.command})); + true + } + Key::Escape => { + app.ui.key_box = None; + true + } + _ => false, + } +} + +impl FigApp { + /// Show a selection box for the keyboard: around the selection, else in the middle of the view. + pub(crate) fn start_key_box(&mut self) { + let Ok(ds) = self.session.ds() else { return }; + let zoom = ds.view.zoom.max(1e-6); + let around = ds.selection.iter().filter_map(|i| ds.doc.world_bounds(*i)).reduce(|a, b| a.union(b)); + let r = match around { + Some(r) => r.inflate(8.0 / zoom, 8.0 / zoom), + None => { + let c = self.to_page(self.canvas.rect.center()); + figcraft_geom::Rect::new(c.x - 100.0 / zoom, c.y - 75.0 / zoom, c.x + 100.0 / zoom, c.y + 75.0 / zoom) + } + }; + self.ui.key_box = Some([r.x0, r.y0, r.x1, r.y1]); + self.toast(crate::i18n::tr("Selection box: arrows move it, Shift+arrows size it, Enter selects, Escape cancels")); + } +} + /// The digit a number key types. fn digit(key: Key) -> Option { let name = key.name(); diff --git a/docs/mcp.md b/docs/mcp.md index 3b7f517..35e79e4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -197,6 +197,19 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: the keyboard between the app's regions (toolbar, layers, canvas, properties). In the app, Tab and Shift+Tab then move between the region's controls, Enter or Space activate one, the arrows move along the toolbar and through the layer rows, and Escape returns to the canvas (no-ops headless). +- `object.rename {ids?}` without a `name` (`Cmd+R`, or `F2`): rename the layer inline in the app's + layers panel, also from a layer row that has the keyboard; when the rename ends the row gets the + keyboard back (Escape cancels it there). Headless it changes nothing and leaves no undo step. +- `view.contextMenu` (`Shift+F10`): the selection's context menu, at its layer row when the layers + panel has the keyboard (after F6 or a click on a row), else on the canvas below the selection; the + menu takes the keyboard and gives it back when it closes (no-op headless). +- `select.area {x, y, width, height, add?, deep?}`: select what a marquee from `x, y` over that box + (page units) would: top-level layers, a frame only when the box covers it, the children of the + top-level frame the box starts in; `add` toggles like a Shift marquee, `deep` takes nested layers + like Cmd/Ctrl. Returns `{selection}`. Without a box (`F8` in the app) it draws one for the + keyboard around the selection (or in the middle of the view): the arrows move it, Shift+arrows + move its far corner (10 screen pixels a press), Enter selects (Shift+Enter adds, Cmd/Ctrl+Enter + nested layers), Escape or a click drops it. - `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's accessibility setting turns it on too). Prototype transitions still play. - `pref.language {language: system|en|es|…}`: the app's interface language, applied live ("system": @@ -557,8 +570,8 @@ Variables come from the local variables response (`GET /v1/files/:key/variables/ collections with their modes (the default mode first), values per mode, aliases and scopes. Pass it as `variables` (a path, or the JSON object or text) to `open_file`, `file.open` or `file.import`, put it next to the file as `.variables.json` or `variables.json`, or embed its `meta` in the -file JSON. Layers' `boundVariables` (sizes, gaps and padding, corner radii, opacity, stroke weight, -visibility, text, font properties, single solid fills and strokes) and `explicitVariableModes` then +file JSON. Layers' `boundVariables` (sizes, gaps and padding, corner radii, opacity, stroke weight and +per-side stroke weights, visibility, text, font properties, single solid fills and strokes) and `explicitVariableModes` then link to them, so switching a mode with `variable.setMode` restyles the layers (`mode: null` sets a frame back to Auto, inheriting its parent's mode). @@ -573,6 +586,13 @@ skipped with a warning; library copies and extended collections are never target added; aliases are checked for loops; it returns `{collections, variables, updated, warnings}`. The Variables editor's Import… and Export… buttons do the same through file dialogs. +Strokes take colour styles and variables like fills: `style.apply {style, target: "stroke"}` links +a colour style to the strokes (`styles.stroke`; edits of the style follow, `style.detach {which: +"stroke"}` keeps the colour), and `variable.bind` binds `strokeWeight` and each side's weight on its +own (`strokeTopWeight`, `strokeRightWeight`, `strokeBottomWeight`, `strokeLeftWeight`; binding or +setting one side with `node.set` turns on individual strokes, the other sides at the weight) to +number variables scoped for stroke weights. + `variable.bind` also binds gradient stops (`fills//stops//color`, `strokes//stops//color`; the binding follows its stop when an earlier stop is removed and its paint when the list is reordered) and variant properties of instances (`componentProperties/` with a string diff --git a/docs/roadmap.md b/docs/roadmap.md index 4fe3199..383450b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -47,7 +47,7 @@ blockers. |---|---|---|---| | M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | — | — | | M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform) | — | — | -| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | keyboard box selection, interaction pass (Open recent and the outline mode variants landed 2026-10-11) | 6–12 + pass | +| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | interaction pass (Open recent, the outline mode variants and keyboard box selection landed 2026-10-11) | 6–12 + pass | | M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 | | M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 | | M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11) | spell check, Figma Draw multi-edit | 7–18 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 75a5366..a28fbcd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -19,9 +19,9 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Feature breadth (does each Figma Design feature exist, weighted) | **~80%** (core editor areas 1–16: ~91%) | estimated from the area table below | | Ready for real work | **~49%** | estimated (dimension table below: written weights × written per-dimension scores) | | Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) | -| Mainstream practitioner | **~46%** | estimated ([below](#mainstream-practitioner): weekly areas 67.8% × discounts 0.9 × 0.9 × 0.85) | +| Mainstream practitioner | **~46%** | estimated ([below](#mainstream-practitioner): weekly areas 68.1% × discounts 0.9 × 0.9 × 0.85) | | Remaining to beta | **~173–310 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~321–618 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~320–615 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -96,10 +96,10 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects |---|---|---|---|---|---|---| | 1 | Canvas & navigation | 6 | 97% | 72% | Display P3 on screen, QA at extreme zooms (outlines for the selection and mask outlines landed) | 2–5 | | 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 | 95% | 78% | Keyboard box selection, keyboard rename/context menu, long-list QA | 2–4 | +| 3 | Layers panel | 5 | 98% | 82% | QA with long real files (keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer reveal/expand/collapse/arrow timings landed) | 1–2 | | 4 | Shapes & vector | 8 | 97% | 68% | Vector QA with real files (live boolean editing, boolean hit testing on the result, arc fields, shape builder on strokes and open paths and one-sided widths landed) | 1–2 | | 5 | Paint | 7 | 87% | 66% | Video fills, wide-gamut canvas, embedded image profiles, HEIC images (animated GIF fills and TIFF images landed) | 4–7 | -| 6 | Strokes | 3 | 90% | 72% | Stroke styles (brush strokes landed, see 20) | 1–3 | +| 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% | 72% | Glass and progressive blur tuned side by side | 3–6 | | 8 | Frames, groups, sections, masks | 5 | 95% | 75% | Visual QA with real files | 2–4 | | 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files; wrapped-line content alignment (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around and cell alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | @@ -115,14 +115,14 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **163–332** | -| | Core editor only (1–16, ÷94) | 94 | ~91% | ~68% | | 58–113 | +| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **162–329** | +| | Core editor only (1–16, ÷94) | 94 | ~91% | ~68% | | 57–110 | ## By dimension (ready for real work) | Dimension | Weight | Ready | Hours | Evidence | Doc | |---|---|---|---|---|---| -| Features (depth) | 35 | 57% | 60–115 (areas 1–13, 15, 16, 20) | The table above; core editor ~68% | this file | +| Features (depth) | 35 | 57% | 59–112 (areas 1–13, 15, 16, 20) | The table above; core editor ~68% | this file | | UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | | File formats | 12 | 55% | 8–15 | `.fig` read, best effort (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) | | Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | @@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | | Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~49%** | **~321–618** | | | +| **Ready for real work** | 100 | **~49%** | **~320–615** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -187,7 +187,7 @@ libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a produc localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration and is left out as an area, but its effect on working with Figma users is counted in discount 3. -**2. Average depth** ("Ready" column, weighted): **67.8%**. +**2. Average depth** ("Ready" column, weighted): **68.1%**. **3. Discounts for what still stops real work:** @@ -197,7 +197,7 @@ and is left out as an area, but its effect on working with Figma users is counte | Stability on real machines | ×0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's ×0.92 because there is no outside evidence either way | | File exchange with Figma users | ×0.85 | A Figma designer's work lives in shared Figma files. FigCraft opens a `.fig` (best effort, not yet checked on real files) but writes nothing Figma opens as a document; incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) | -67.8% × 0.90 × 0.90 × 0.85 = **~46%** (estimated). +68.1% × 0.90 × 0.90 × 0.85 = **~46%** (estimated). **4. User evidence (2026-10-10):** 0 issues, 0 praise or "switched from Figma" reports, 0 comments from anyone but the maintainers (`gh issue list`, `gh api .../issues/comments`; the repository is @@ -523,8 +523,9 @@ Hours in this narrative are the builders' estimates; the tables above supersede coming only until it arrives (`ui.inspect` `framesDrawn` and `repaintCauses` show what asked for frames; headless tests hold it). *Left:* a real-world file corpus, Windows/Linux/browser QA, runs with real screen readers (NVDA, Narrator, VoiceOver, Orca), the AccessKit tree in the - browser (eframe's web backend doesn't send it yet), keyboard box selection, keyboard rename and - context menus in the layers panel. 28–46 h. + browser (eframe's web backend doesn't send it yet). Landed since: F2 rename from the keyboard, + Shift+F10 context menus at the focused layer row or the canvas selection, keyboard box selection + (F8). 27–44 h. ## Inventory: what exists today @@ -590,6 +591,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h | | 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h | | 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h | | 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Import/export breadth 65% → 80%, ready 35% → 50%, 30–60 h → 8–15 h; file formats 40% → 55%, 30–60 h → 8–15 h; feature breadth ~79% → ~80% (core ~90% → ~91%), areas ready ~57% → ~58% (core ~67% → ~68%); ready for real work ~47% → ~49%; essentials ~53% → ~57% (opening files people send ×0.80 → ×0.88); mainstream ~43% → ~46% (weekly areas 66.8% → 67.8%, file exchange ×0.80 → ×0.85); beta ~195–355 h → ~173–310 h; full parity ~345–666 h → ~323–621 h | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2f74f10..e71c5f3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -20,7 +20,7 @@ interaction feel not yet compared. | Minimize UI (Cmd+\\) | done | | | Resizable panels | partial | check widths and snap points against Figma | | Actions palette (Cmd+K, Cmd+/) | done | commands; no assets search parity | -| Context menus | done | canvas and layers; keyboard access to the layers context menu missing | +| Context menus | done | canvas and layers; Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | | Main menu from the logo | done | generated from `catalog.rs`; popups sized to content | | Keyboard shortcuts panel | done | docked, tabbed by group, searchable, per-platform keycaps | | Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored | @@ -47,7 +47,7 @@ interaction feel not yet compared. | Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | partial | check scrub speed and Shift/Alt steps | | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | -| Keyboard box selection | missing | | +| Keyboard box selection | done | F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Shift+arrows size it, Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested); our addition for keyboard-only users | ## Tools @@ -90,6 +90,7 @@ interaction feel not yet compared. |---|---|---| | Screen-reader names, roles, values, states (AccessKit) | done on desktop | browser: eframe doesn't send the tree yet | | Keyboard navigation: F6 regions, Tab within, focus ring | done | | +| Layers panel from the keyboard: arrows, F2 / Cmd+R rename (the row gets the keyboard back), Shift+F10 context menu; 10k-layer lists | done | revealing a layer, expanding, collapsing and arrow keys timed in `cargo xtask bench` | | Reduced motion | done | system setting or preference | | WCAG AA contrast of secondary and accent text | done | | | Tested with VoiceOver, NVDA, Narrator, Orca | missing | human QA | @@ -98,6 +99,7 @@ interaction feel not yet compared. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Layers panel keyboard: F2 renames (also from a row with the keyboard, which gets it back), Shift+F10 opens the context menu at the focused row or below the canvas selection, F8 draws a selection box the arrows move and size; stroke weights bind to variables side by side | | 2026-10-11 | minor | Selection and transform behaviour suite and fixes: Alt shrinks, groups and mixed rotations scale each layer along its own axes, several layers flip together, whole-pixel resizes, small layers move from inside, locked layers stay put, marquees from a top-level frame's background, equal spacing snapping, Cmd/Ctrl ignores constraints while resizing a frame | | 2026-10-11 | minor | Gamut warnings: the colour picker flags Display P3 colours outside sRGB; View ▸ Soft-proof sRGB stripes them on the canvas | | 2026-10-11 | minor | Several windows: File ▸ New window, Open in new window on a tab, a tab dragged off the strip; each window has its own tabs and panels and asks about unsaved changes when it closes |