From 09a3b7446607d8a988125fd395d946dbbdaa1e90 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 11 Oct 2026 14:25:25 +0700 Subject: [PATCH] M8: canvas fixes: boolean hit testing, group drags, frame outlines and slices view, a flaky idle test (#123) Clicks on a boolean group test its computed result, so a click in a subtracted hole reaches what is behind it. Layers inside a group, boolean group or instance stay in their parent when dragged (one shared rule). View > Frame outlines and Slices now draw. The idle test's flake came from the render worker clearing its in-flight flag before waking the UI; jobs now carry sequence numbers. --- ROADMAP.md | 6 +- apps/figcraft-cli/src/perf.rs | 5 +- crates/doc/src/boolean_hit_tests.rs | 6 +- crates/doc/src/geometry.rs | 79 +++++++++------ crates/doc/src/lib.rs | 7 +- crates/doc/src/tests.rs | 12 +-- crates/engine/src/booleans.rs | 18 +++- crates/engine/src/canvas_fixes_tests.rs | 107 ++++++++++++++++++++ crates/engine/src/cmd/edit.rs | 4 +- crates/engine/src/drop.rs | 9 +- crates/engine/src/frame_label_tests.rs | 2 +- crates/engine/src/lib.rs | 2 + crates/engine/src/section_contents_tests.rs | 4 +- crates/engine/src/shapebuilder.rs | 2 +- crates/engine/src/tools.rs | 20 ++-- crates/ui-egui/src/canvas.rs | 18 +++- crates/ui-egui/src/canvas_drag_tests.rs | 4 +- crates/ui-egui/src/canvas_fixes_tests.rs | 91 +++++++++++++++++ crates/ui-egui/src/idle_tests.rs | 21 ++-- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/live_tests.rs | 5 +- crates/ui-egui/src/menus.rs | 15 +-- crates/ui-egui/src/render_worker.rs | 40 +++++--- docs/gaps.md | 16 +-- docs/mcp.md | 6 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 9 +- docs/ui-parity.md | 9 +- 28 files changed, 405 insertions(+), 119 deletions(-) create mode 100644 crates/engine/src/canvas_fixes_tests.rs create mode 100644 crates/ui-egui/src/canvas_fixes_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index f8173e07..5af115c9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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 ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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 ~79%; ready ~47%) · **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 @@ -127,6 +127,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-11** · Canvas fixes: clicks hit boolean groups where their result is (a click in a + subtracted hole reaches what's behind), a layer dragged inside a group stays in it, View ▸ Frame + outlines and View ▸ Slices draw, and a flaky idle test now waits for the render thread (gap 13). - **2026-10-11** · Team libraries on `figcraft-server` (gap 10): Assets ▸ Libraries ▸ Publish to server keeps the file's library on the server under the file (per-asset versions; the server's version is followed); other files enable it by pasting a share link (a view link is enough), get "Library updates available" from a background check @@ -171,6 +174,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | | 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | | 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h | | 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | diff --git a/apps/figcraft-cli/src/perf.rs b/apps/figcraft-cli/src/perf.rs index 7c23b378..0d5d1c8c 100644 --- a/apps/figcraft-cli/src/perf.rs +++ b/apps/figcraft-cli/src/perf.rs @@ -157,7 +157,10 @@ fn measure(table: &mut Table, col: usize, layers: usize) -> CliResult<()> { let (fx, fy) = ((i * 7919 % 1000) as f64 / 1000.0, (i * 104_729 % 1000) as f64 / 1000.0); Point::new(bounds.x0 + bounds.width() * fx, bounds.y0 + bounds.height() * fy) }; - table.put(col, "hit test (µs each)", mean(hits, |i| _ = doc.hit_test(page, pt(i), 1.0)) * 1000.0); + { + let result = figcraft_engine::booleans::result_outline(&doc, &[]); + table.put(col, "hit test (µs each)", mean(hits, |i| _ = doc.hit_test(page, pt(i), 1.0, Some(&result))) * 1000.0); + } measure_boolean_drag(table, col); let mut s = Session::with_document(doc); diff --git a/crates/doc/src/boolean_hit_tests.rs b/crates/doc/src/boolean_hit_tests.rs index a7774e07..b826f202 100644 --- a/crates/doc/src/boolean_hit_tests.rs +++ b/crates/doc/src/boolean_hit_tests.rs @@ -27,8 +27,8 @@ fn setup() -> (Document, NodeId, NodeId, NodeId, NodeId, NodeId) { fn hits_reach_operands_but_clicks_pick_the_group() { let (d, f, g, b, r1, r2) = setup(); let p = page(&d); - assert_eq!(d.hit_test(p, Point::new(20.0, 20.0), 0.0), Some(r1)); - assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0), Some(r2), "the top operand where they overlap"); + assert_eq!(d.hit_test(p, Point::new(20.0, 20.0), 0.0, None), Some(r1)); + assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), Some(r2), "the top operand where they overlap"); // A plain click: the group in the frame; Cmd-click: the operand. assert_eq!(select_target(&d, r1, &[], false), g); assert_eq!(select_target(&d, r1, &[], true), r1); @@ -36,7 +36,7 @@ fn hits_reach_operands_but_clicks_pick_the_group() { // click on another operand picks that one. assert_eq!(select_target(&d, r2, &[r1], false), r2); // "Select layer" still lists the boolean group as one layer. - let listed: Vec = crate::geometry::layers_at(&d, p, Point::new(20.0, 20.0), 0.0).into_iter().map(|(id, _)| id).collect(); + let listed: Vec = crate::geometry::layers_at(&d, p, Point::new(20.0, 20.0), 0.0, None).into_iter().map(|(id, _)| id).collect(); assert_eq!(listed, vec![f, g, b]); } diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index c4b90ea6..4ecae60e 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -66,31 +66,46 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64) - NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => { n.local_rect().inflate(tol_l, tol_l).contains(lp) } - _ => { - let Some(path) = shape_path(n) else { return false }; - if is_closed(n) && (n.fills.iter().any(|f| f.visible) || !n.has_visible_stroke()) && path.winding(lp) != 0 { - return true; - } - // Near the outline. - let reach = (n.stroke.max_weight() / 2.0).max(tol_l); - if !path.bounding_box().inflate(reach, reach).contains(lp) { - return false; - } - near_path(&path, lp, reach) - } + _ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, is_closed(n) && path.winding(lp) != 0)), } } +/// Whether local point `lp` hits `n` drawn as `path`: `inside` it (counted when it's filled, or +/// draws nothing at all), or within reach of its outline (its stroke, else the tolerance). +fn hits_path(n: &Node, path: &BezPath, lp: Point, tol: f64, inside: bool) -> bool { + if inside && (n.fills.iter().any(|f| f.visible) || !n.has_visible_stroke()) { + return true; + } + let reach = (n.stroke.max_weight() / 2.0).max(tol); + if !path.bounding_box().inflate(reach, reach).contains(lp) { + return false; + } + near_path(path, lp, reach) +} + +/// A boolean group's computed result outline in its local space (filled even-odd), or `None` to +/// hit-test its operands instead. Boolean operations live above this crate: callers that can +/// compute results pass them in; `None` for the whole argument hit-tests operands everywhere. +pub type BoolResult<'a> = Option<&'a dyn Fn(NodeId, &Node) -> Option>; + +/// With a result for boolean group `id`, whether `pt` hits it (`None`: no result, test operands). +fn hits_result(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, result: BoolResult) -> Option { + let path = result.and_then(|f| f(id, n))?; + let lp = doc.world_transform(id).inverse() * pt; + Some(hits_path(n, &path, lp, tol, path.winding(lp) % 2 != 0)) +} + fn near_path(path: &BezPath, p: Point, dist: f64) -> bool { use figcraft_geom::kurbo::ParamCurveNearest; path.segments().any(|s| s.nearest(p, 0.01).distance_sq <= dist * dist) } -/// The innermost visible, unlocked node under `pt` (page coordinates), honouring clip content. -pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64) -> Option { +/// The innermost visible, unlocked node under `pt` (page coordinates), honouring clip content; +/// boolean groups are hit where their `result` is. +pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option { let p = doc.node(page)?; for c in p.children.iter().rev() { - if let Some(h) = hit_rec(doc, *c, pt, tol, 0) { + if let Some(h) = hit_rec(doc, *c, pt, tol, 0, result) { return Some(h); } } @@ -98,11 +113,11 @@ pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64) -> Option Option { - hit_rec(doc, root, pt, tol, 0) +pub fn hit_in(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option { + hit_rec(doc, root, pt, tol, 0, result) } -fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize) -> Option { +fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult) -> Option { if depth > 512 { return None; } @@ -117,15 +132,19 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize) -> Opt if clips && !hits_node_itself(doc, id, n, pt, 0.0) { return None; } - // A boolean group is hit where an operand is (an approximation of its result). The leaf is the - // operand, so a plain click still selects the group (see `select_target`) while a deep click, - // a double click or a selected sibling operand reaches the operand itself. + // A boolean group is hit where its result is (without one: where an operand is). The leaf is + // the operand under the point, so a plain click still selects the group (see `select_target`) + // while a deep click, a double click or a selected sibling operand reaches the operand itself. + let in_result = if matches!(n.kind, NodeKind::BooleanOp { .. }) { hits_result(doc, id, n, pt, tol, result) } else { None }; + if in_result == Some(false) { + return None; + } for c in n.children.iter().rev() { - if let Some(h) = hit_rec(doc, *c, pt, tol, depth + 1) { + if let Some(h) = hit_rec(doc, *c, pt, tol, depth + 1, result) { return Some(h); } } - if hits_node_itself(doc, id, n, pt, tol) { + if in_result == Some(true) || hits_node_itself(doc, id, n, pt, tol) { return Some(id); } None @@ -133,10 +152,10 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize) -> Opt /// Every visible, unlocked layer under `pt` (page coordinates) with its depth below the page, in /// layer-tree order (each layer before what it contains, the front-most first): what -/// "Select layer" in the context menu lists. Clip content is honoured; boolean groups and -/// sections with hidden contents count as one layer. -pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64) -> Vec<(NodeId, usize)> { - fn rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, out: &mut Vec<(NodeId, usize)>) { +/// "Select layer" in the context menu lists. Clip content is honoured; boolean groups (hit where +/// their `result` is) and sections with hidden contents count as one layer. +pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Vec<(NodeId, usize)> { + fn rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, out: &mut Vec<(NodeId, usize)>) { let Some(n) = doc.node(id) else { return }; if !n.visible || n.locked || depth > 512 { return; @@ -148,13 +167,13 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64) -> Vec<(Node let at = out.len(); let whole = matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Section { contents_hidden: true }); if whole { - if hit_rec(doc, id, pt, tol, depth).is_some() { + if hit_rec(doc, id, pt, tol, depth, result).is_some() { out.push((id, depth)); } return; } for c in n.children.iter().rev() { - rec(doc, *c, pt, tol, depth + 1, out); + rec(doc, *c, pt, tol, depth + 1, result, out); } if out.len() > at || hits_node_itself(doc, id, n, pt, tol) { out.insert(at, (id, depth)); @@ -163,7 +182,7 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64) -> Vec<(Node let mut out = Vec::new(); if let Some(p) = doc.node(page) { for c in p.children.iter().rev() { - rec(doc, *c, pt, tol, 0, &mut out); + rec(doc, *c, pt, tol, 0, result, &mut out); } } out diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c2834289..5e8b127f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -980,9 +980,10 @@ impl Document { /// Hit test: the topmost visible, unlocked node under `pt` on `page` for a click. /// `deep` selects the innermost leaf (Cmd-click); otherwise top-level children of frames - /// resolve like Figma's click-through rules (see [`geometry::select_target`]). - pub fn hit_test(&self, page: NodeId, pt: Point, tolerance: f64) -> Option { - geometry::hit_leaf(self, page, pt, tolerance) + /// resolve like Figma's click-through rules (see [`geometry::select_target`]). Boolean groups + /// are hit where their `result` is (see [`geometry::BoolResult`]). + pub fn hit_test(&self, page: NodeId, pt: Point, tolerance: f64, result: geometry::BoolResult) -> Option { + geometry::hit_leaf(self, page, pt, tolerance, result) } } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index b5f864a8..eeaff4fd 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -116,12 +116,12 @@ fn hit_test_respects_z_and_clip() { let a = d.create(f, NodeKind::Rectangle, "A", 10.0, 10.0, 50.0, 50.0).unwrap(); let b = d.create(f, NodeKind::Rectangle, "B", 30.0, 30.0, 50.0, 50.0).unwrap(); let outside = d.create(f, NodeKind::Rectangle, "Out", 90.0, 90.0, 50.0, 50.0).unwrap(); - assert_eq!(d.hit_test(p, Point::new(40.0, 40.0), 0.0), Some(b)); - assert_eq!(d.hit_test(p, Point::new(15.0, 15.0), 0.0), Some(a)); - assert_eq!(d.hit_test(p, Point::new(95.0, 95.0), 0.0), Some(outside)); + assert_eq!(d.hit_test(p, Point::new(40.0, 40.0), 0.0, None), Some(b)); + assert_eq!(d.hit_test(p, Point::new(15.0, 15.0), 0.0, None), Some(a)); + assert_eq!(d.hit_test(p, Point::new(95.0, 95.0), 0.0, None), Some(outside)); // Clipped part of `outside` isn't hit. - assert_eq!(d.hit_test(p, Point::new(120.0, 120.0), 0.0), None); - assert_eq!(d.hit_test(p, Point::new(5.0, 95.0), 0.0), Some(f)); + assert_eq!(d.hit_test(p, Point::new(120.0, 120.0), 0.0, None), None); + assert_eq!(d.hit_test(p, Point::new(5.0, 95.0), 0.0, None), Some(f)); } #[test] @@ -132,7 +132,7 @@ fn click_target_rules() { let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 0.0, 0.0).unwrap(); let r = d.create(g, NodeKind::Rectangle, "R", 10.0, 10.0, 20.0, 20.0).unwrap(); d.fit_to_children(g).unwrap(); - let leaf = d.hit_test(p, Point::new(15.0, 15.0), 0.0).unwrap(); + let leaf = d.hit_test(p, Point::new(15.0, 15.0), 0.0, None).unwrap(); assert_eq!(leaf, r); // Child of a top-level frame: the group. assert_eq!(geometry::select_target(&d, leaf, &[], false), g); diff --git a/crates/engine/src/booleans.rs b/crates/engine/src/booleans.rs index 53553d9b..87d90153 100644 --- a/crates/engine/src/booleans.rs +++ b/crates/engine/src/booleans.rs @@ -3,7 +3,9 @@ //! - **Selecting operands.** A click selects the boolean group; a double click (or Cmd/Ctrl-click, //! or a click while a sibling operand is selected) selects the operand under the pointer, and a //! double click on a selected shape operand enters vector edit mode on it. Enter selects every -//! operand. +//! operand. Clicks hit a boolean group where its result is (a click in a subtracted hole goes +//! through to what's behind), except while it or a layer in it is selected: then its operands +//! are hit, so each can be picked ([`result_outline`]). //! - **Live results.** Moving, resizing or editing the points of an operand changes the document //! on every pointer move, so the group's result is redrawn during the drag (one undo step per //! gesture); a dragged operand stays in its group. The renderer caches every prefix of the group's fold, so only the operands from the @@ -15,7 +17,8 @@ //! group's (and every operand follows it again); otherwise the selection is wrapped in a new //! boolean group ([`apply`]). -use figcraft_doc::{BoolOp, Document, NodeId, NodeKind}; +use figcraft_doc::{BoolOp, Document, Node, NodeId, NodeKind}; +use figcraft_geom::BezPath; use serde_json::{Value, json}; use crate::params::req_targets; @@ -62,6 +65,17 @@ pub fn shown_op(doc: &Document, ids: &[NodeId]) -> Option { ops.all(|o| o == Some(first)).then_some(first) } +/// Boolean group results for hit testing ([`figcraft_doc::geometry::BoolResult`]): none for a +/// group that is selected or holds a selected layer, whose operands are hit instead. +pub fn result_outline<'a>(doc: &'a Document, selection: &'a [NodeId]) -> impl Fn(NodeId, &Node) -> Option + 'a { + move |id, n| { + if selection.iter().any(|s| doc.is_ancestor(id, *s)) { + return None; + } + figcraft_render::node_outline(doc, n).map(|(p, _)| p) + } +} + /// The operands to outline on the canvas while layers inside a boolean group are selected or /// edited (`editing`: the layer in vector edit mode): every operand of the nearest boolean group /// holding each of them, in layer order, without repeats. diff --git a/crates/engine/src/canvas_fixes_tests.rs b/crates/engine/src/canvas_fixes_tests.rs new file mode 100644 index 00000000..be4da06d --- /dev/null +++ b/crates/engine/src/canvas_fixes_tests.rs @@ -0,0 +1,107 @@ +//! Canvas fixes: boolean groups are clicked where their result is (a click in a subtracted hole +//! goes through), and a layer dragged inside a group stays in the group while frames still take +//! drops. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::NodeId; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> NodeId { + id_of(&s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))["id"]).unwrap_or(NodeId(0)) +} + +fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent { + PointerEvent { cmd, ..PointerEvent::at(kind, x, y, 1.0) } +} + +fn click(s: &mut Session, x: f64, y: f64, cmd: bool) { + s.pointer(&ev(PointerKind::Down, x, y, cmd)).unwrap(); + s.pointer(&ev(PointerKind::Up, x, y, cmd)).unwrap(); +} + +fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) { + s.pointer(&ev(PointerKind::Down, from.0, from.1, false)).unwrap(); + for k in 1..=4 { + let t = f64::from(k) / 4.0; + s.pointer(&ev(PointerKind::Move, from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t, false)).unwrap(); + } + s.pointer(&ev(PointerKind::Up, to.0, to.1, false)).unwrap(); +} + +fn no_snapping(s: &mut Session) { + let ds = s.ds_mut().unwrap(); + ds.view.snap_objects = false; + ds.view.snap_pixel = false; +} + +fn parent(s: &Session, id: NodeId) -> Option { + s.doc().unwrap().node(id).unwrap().parent +} + +/// A 200 × 200 ring (a square minus a square hole at 50..150) over a rectangle filling the hole, +/// and nothing behind the ring's top-right corner: (behind, ring, outer, hole). +fn ring_over_a_rectangle(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { + let behind = ex(s, "shape.rectangle", json!({"x": 60, "y": 60, "width": 80, "height": 80})); + let outer = ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200})); + let hole = ex(s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100})); + let ring = ex(s, "bool.subtract", json!({"ids": [outer, hole]})); + s.execute("select.none", &json!({})).unwrap(); + no_snapping(s); + (behind, ring, outer, hole) +} + +#[test] +fn a_click_in_a_booleans_hole_goes_through_to_what_is_behind() { + let mut s = Session::new(); + let (behind, ring, outer, hole) = ring_over_a_rectangle(&mut s); + click(&mut s, 100.0, 100.0, false); + assert_eq!(s.selection(), vec![behind], "the shape seen through the hole"); + // (Away from the selected rectangle's corner handles.) + s.execute("select.none", &json!({})).unwrap(); + click(&mut s, 145.0, 55.0, false); + assert!(s.selection().is_empty(), "the hole's corner, with nothing behind it: nothing"); + click(&mut s, 25.0, 25.0, false); + assert_eq!(s.selection(), vec![ring], "the ring itself"); + // Operand rules: Cmd/Ctrl-click reaches the operand under the pointer, and inside the group + // (it or an operand selected) every operand can be picked, the hole's too. + click(&mut s, 25.0, 25.0, true); + assert_eq!(s.selection(), vec![outer]); + click(&mut s, 100.0, 100.0, false); + assert_eq!(s.selection(), vec![hole], "a sibling operand, in the hole"); + s.execute("select.set", &json!({"ids": [ring]})).unwrap(); + s.pointer(&ev(PointerKind::DoubleClick, 100.0, 100.0, false)).unwrap(); + assert_eq!(s.selection(), vec![hole], "a double click enters the group"); + // "Select layer" doesn't list the ring in its hole. + s.execute("select.none", &json!({})).unwrap(); + let listed = s.execute("select.layersAt", &json!({"x": 100, "y": 100})).unwrap(); + let ids: Vec = listed["layers"].as_array().unwrap().iter().filter_map(|l| id_of(&l["id"])).collect(); + assert_eq!(ids, vec![behind]); +} + +#[test] +fn a_layer_dragged_in_a_group_stays_in_it() { + let mut s = Session::new(); + let frame = ex(&mut s, "frame.create", json!({"x": 400, "y": 0, "width": 300, "height": 300})); + let a = ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})); + let b = ex(&mut s, "shape.rectangle", json!({"x": 100, "y": 0, "width": 50, "height": 50})); + let g = ex(&mut s, "object.group", json!({"ids": [a, b]})); + no_snapping(&mut s); + // Into the frame, then out onto the empty page: the layer stays in its group each time. + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + drag(&mut s, (25.0, 25.0), (525.0, 125.0)); + assert_eq!(parent(&s, a), Some(g), "dropped over a frame: still in its group"); + assert_eq!(s.doc().unwrap().world_bounds(a).unwrap().x0, 500.0, "it moved"); + drag(&mut s, (525.0, 125.0), (225.0, 425.0)); + assert_eq!(parent(&s, a), Some(g), "dropped on the page: still in its group"); + let gb = s.doc().unwrap().world_bounds(g).unwrap(); + assert_eq!((gb.x0, gb.y0, gb.x1, gb.y1), (100.0, 0.0, 250.0, 450.0), "the group's box follows its layers"); + // A layer that isn't in a group still drops into the frame. + s.execute("select.set", &json!({"ids": [g]})).unwrap(); + s.execute("object.ungroup", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + drag(&mut s, (225.0, 425.0), (525.0, 125.0)); + assert_eq!(parent(&s, a), Some(frame), "outside a group, a frame takes the drop"); +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e3d3b663..90b3d3b0 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -658,7 +658,9 @@ fn layers_at(s: &mut Session, p: &Value) -> Result { let (x, y) = (req_num("select.layersAt", p, "x")?, req_num("select.layersAt", p, "y")?); let doc = s.doc()?; let zoom = s.ds()?.view.zoom.max(1e-6); - let layers: Vec = figcraft_doc::geometry::layers_at(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom) + let sel = s.selection(); + let result = crate::booleans::result_outline(doc, &sel); + let layers: Vec = figcraft_doc::geometry::layers_at(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom, Some(&result)) .into_iter() .filter_map(|(id, depth)| doc.node(id).map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth}))) .collect(); diff --git a/crates/engine/src/drop.rs b/crates/engine/src/drop.rs index d614d621..f8533a91 100644 --- a/crates/engine/src/drop.rs +++ b/crates/engine/src/drop.rs @@ -159,6 +159,13 @@ pub fn drop_parent(doc: &Document, page: NodeId, p: Point, exclude: &[NodeId]) - doc.node(page)?.children.iter().rev().find_map(|c| rec(doc, *c, p, exclude, false, false, 0)) } +/// Whether layers dragged on the canvas inside `parent` stay in it (they only move): those of a +/// group or a boolean group (which follow their layers) and of an instance outside its slots. +pub fn keeps_layers(doc: &Document, parent: NodeId) -> bool { + matches!(doc.node(parent).map(|n| &n.kind), Some(NodeKind::Group | NodeKind::BooleanOp { .. })) + || crate::components::structure_locked(doc, parent) +} + /// Margin (page units) around the auto layout frame a dragged layer comes from that still keeps it. const HOME_MARGIN: f64 = 8.0; @@ -201,7 +208,7 @@ pub fn swap_family(doc: &Document, component: NodeId) -> Option { /// under the point (innermost first; never one inside the dropped component `exclude`), the first /// showing a variant of the same component set (`family`), else the innermost. pub fn swap_target(doc: &Document, page: NodeId, p: Point, family: Option<&str>, exclude: Option) -> Option { - let hit = doc.hit_test(page, p, 0.0)?; + let hit = doc.hit_test(page, p, 0.0, Some(&crate::booleans::result_outline(doc, &[])))?; let chain: Vec = std::iter::once(hit) .chain(doc.ancestors(hit)) .filter(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Instance { .. }))) diff --git a/crates/engine/src/frame_label_tests.rs b/crates/engine/src/frame_label_tests.rs index 9260a6fb..8e9ede7b 100644 --- a/crates/engine/src/frame_label_tests.rs +++ b/crates/engine/src/frame_label_tests.rs @@ -40,7 +40,7 @@ fn pressing_a_rotated_frames_label_selects_and_drags_the_frame() { let c = center(&q); let page = s.page().unwrap(); // The label lies outside the frame: nothing else is there. - assert_eq!(s.doc().unwrap().hit_test(page, c, 1.0), None); + assert_eq!(s.doc().unwrap().hit_test(page, c, 1.0, None), None); // Hover highlights the frame. s.pointer(&ev(PointerKind::Hover, c)).unwrap(); assert_eq!(s.ds().unwrap().hover, Some(f)); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bdaca76f..9d16aba7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1191,6 +1191,8 @@ mod branch_tests; #[cfg(test)] mod brush_tests; #[cfg(test)] +mod canvas_fixes_tests; +#[cfg(test)] mod code_connect_tests; #[cfg(test)] mod collab_ids_tests; diff --git a/crates/engine/src/section_contents_tests.rs b/crates/engine/src/section_contents_tests.rs index 8deeafdc..feb5bae0 100644 --- a/crates/engine/src/section_contents_tests.rs +++ b/crates/engine/src/section_contents_tests.rs @@ -37,7 +37,7 @@ fn hidden_contents_skip_rendering_and_hit_testing() { let r = id(s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40, "parent": sec})).unwrap()); s.execute("node.set", &json!({"id": r, "props": {"fills": ["#FF0000"]}})).unwrap(); let page = s.page().unwrap(); - assert_eq!(s.doc().unwrap().hit_test(page, Point::new(60.0, 60.0), 0.0), Some(r)); + assert_eq!(s.doc().unwrap().hit_test(page, Point::new(60.0, 60.0), 0.0, None), Some(r)); assert_eq!(pixel(&s, sec, 60, 60), [255, 0, 0, 255]); // Hiding: the selected child gives way to the section. s.execute("select.set", &json!({"ids": [r]})).unwrap(); @@ -45,7 +45,7 @@ fn hidden_contents_skip_rendering_and_hit_testing() { assert_eq!(v["hidden"], json!(true)); assert_eq!(s.selection(), vec![sec]); assert_eq!(get(&mut s, sec)["sectionContentsHidden"], json!(true)); - assert_eq!(s.doc().unwrap().hit_test(page, Point::new(60.0, 60.0), 0.0), Some(sec)); + assert_eq!(s.doc().unwrap().hit_test(page, Point::new(60.0, 60.0), 0.0, None), Some(sec)); assert_eq!(pixel(&s, sec, 60, 60), [255, 255, 255, 255], "the child isn't drawn"); // Toggling again shows them; explicit `hidden` sets. s.execute("section.toggleContents", &json!({"ids": [sec]})).unwrap(); diff --git a/crates/engine/src/shapebuilder.rs b/crates/engine/src/shapebuilder.rs index b3fe1265..09a6758a 100644 --- a/crates/engine/src/shapebuilder.rs +++ b/crates/engine/src/shapebuilder.rs @@ -534,7 +534,7 @@ impl Session { fn builder_select(&mut self, ev: &PointerEvent) { let Ok(ds) = self.ds() else { return }; let (doc, page, mut sel) = (ds.doc.clone(), ds.page, ds.selection.clone()); - let Some(leaf) = doc.hit_test(page, ev.p(), 2.0 / ev.zoom.max(1e-6)) else { return }; + let Some(leaf) = self.hit(&doc, page, ev.p(), 2.0 / ev.zoom.max(1e-6)) else { return }; let t = figcraft_doc::geometry::select_target(&doc, leaf, &sel, ev.cmd); if ev.shift { if let Some(i) = sel.iter().position(|x| *x == t) { diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 6e961408..d5d71cc6 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1053,10 +1053,10 @@ impl Session { let p = ev.p(); let home = moving.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent); // Absolute children of an auto layout frame stay in it, and so do the layers of - // an instance (outside its slots: they only move) and the operands of a boolean - // group (its result follows them); the rest may change parent. + // groups, boolean groups and instances ([`crate::drop::keeps_layers`]); the + // rest may change parent. let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute)) - && !home.is_some_and(|h| crate::components::structure_locked(doc, h) || crate::booleans::is_group(doc, h)); + && !home.is_some_and(|h| crate::drop::keeps_layers(doc, h)); let target = if flow { crate::drop::drag_parent(doc, page, p, &moving) } else { home.unwrap_or(page) }; let mode = doc.node(target).and_then(|n| n.layout.as_ref()).map(|l| l.mode); if let (true, Some(mode)) = (flow, mode) { @@ -1449,11 +1449,9 @@ impl Session { let before = ds.doc.clone(); let d = ds.doc_mut(); for m in &moving { - // Instance layers (outside slots) and boolean operands stay put: they only move. + // Group, boolean group and instance layers stay put: they only move. let from = d.node(*m).and_then(|n| n.parent); - if from.is_some_and(|f| crate::components::structure_locked(d, f) || crate::booleans::is_group(d, f)) - || crate::components::structure_locked(d, target) - { + if from.is_some_and(|f| crate::drop::keeps_layers(d, f)) || crate::components::structure_locked(d, target) { continue; } // A layer can't go into itself; it stays where it is. @@ -1816,11 +1814,13 @@ impl Session { } /// The layer under `p` for a click: the topmost on `page`, or in the focus view the focused - /// frame's. + /// frame's. Boolean groups are hit by their results ([`crate::booleans::result_outline`]). pub fn hit(&self, doc: &Document, page: NodeId, p: Point, tol: f64) -> Option { + let sel = self.selection(); + let result = crate::booleans::result_outline(doc, &sel); match self.ds().ok().and_then(crate::DocState::focused) { - Some(f) => figcraft_doc::geometry::hit_in(doc, f, p, tol), - None => doc.hit_test(page, p, tol), + Some(f) => figcraft_doc::geometry::hit_in(doc, f, p, tol, Some(&result)), + None => doc.hit_test(page, p, tol, Some(&result)), } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a05bea11..b5db77e3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -785,10 +785,20 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ for id in operands.iter().filter(|o| !sel.contains(o)) { draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id).gamma_multiply(0.6), 1.0)); } - // Mask outlines: a mask doesn't draw itself. - if view.mask_outlines { - for id in doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| n.is_mask && n.visible)) { - draw_outline(app, painter, &doc, id, stroke(ACCENT, 1.0)); + // Mask outlines (a mask doesn't draw itself), frame outlines and slices, as the View menu shows. + if view.mask_outlines || view.frame_outlines || view.show_slices { + for id in doc.subtree(page) { + let Some(n) = doc.node(id).filter(|n| n.visible) else { continue }; + if view.mask_outlines && n.is_mask { + draw_outline(app, painter, &doc, id, stroke(ACCENT, 1.0)); + } else if view.frame_outlines && n.kind.is_frame_like() { + draw_outline(app, painter, &doc, id, stroke(t.text_secondary, 1.0)); + } else if view.show_slices + && matches!(n.kind, NodeKind::Slice) + && let Some(q) = node_quad(app, &doc, id) + { + painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(t.text_secondary, 1.0), 4.0, 3.0)); + } } } // Drop target / insertion indicator. diff --git a/crates/ui-egui/src/canvas_drag_tests.rs b/crates/ui-egui/src/canvas_drag_tests.rs index fff79442..9bc56fb2 100644 --- a/crates/ui-egui/src/canvas_drag_tests.rs +++ b/crates/ui-egui/src/canvas_drag_tests.rs @@ -15,7 +15,7 @@ use crate::layers_drag_tests::Ui; fn settle(u: &mut Ui) { for _ in 0..300 { u.frame(vec![]); - if u.app.worker.as_ref().is_some_and(|w| !w.in_flight) { + if u.app.worker.as_ref().is_some_and(|w| !w.in_flight()) { return; } std::thread::sleep(Duration::from_millis(10)); @@ -56,6 +56,6 @@ fn a_drag_shows_on_the_canvas_while_it_moves() { // no new documents, so the worker runs dry. settle(&mut u); assert!(Arc::ptr_eq(&held, &u.app.session.ds().unwrap().doc)); - assert!(u.app.worker.as_ref().is_some_and(|w| !w.in_flight), "nothing left to render"); + assert!(u.app.worker.as_ref().is_some_and(|w| !w.in_flight()), "nothing left to render"); u.button(p + vec2(30.0, 10.0), false); } diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs new file mode 100644 index 00000000..0e62a5d6 --- /dev/null +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -0,0 +1,91 @@ +//! Canvas fixes, driven through egui headless: View ▸ Frame outlines and View ▸ Slices draw (off +//! by default), and the render worker counts a job as in flight until it is done. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use egui::{Pos2, Shape}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::flat; +use crate::present_tests::id; + +/// Whether `shapes` hold a closed outline through the corners `q`. +fn outlined(shapes: &[Shape], q: [Pos2; 4]) -> bool { + shapes.iter().any(|s| matches!(s, Shape::Path(p) if p.closed && p.points.len() == 4 && p.points.iter().zip(q).all(|(a, b)| a.distance(b) < 0.5))) +} + +/// How many line segments lie along the top edge from `a` to `b` (a dashed outline's dashes). +fn dashes_along(shapes: &[Shape], a: Pos2, b: Pos2) -> usize { + let on = |p: Pos2| (p.y - a.y).abs() < 0.5 && p.x >= a.x - 0.5 && p.x <= b.x + 0.5; + shapes.iter().filter(|s| matches!(s, Shape::LineSegment { points: [p, q], .. } if on(*p) && on(*q))).count() +} + +#[test] +fn frame_outlines_and_slices_draw_when_shown() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap()); + let inner = id(&s.execute("frame.create", &json!({"x": 20, "y": 20, "width": 80, "height": 60, "parent": f})).unwrap()); + let slice = id(&s.execute("node.create", &json!({"type": "slice", "x": 300, "y": 0, "width": 100, "height": 50})).unwrap()); + s.execute("select.set", &json!({"ids": []})).unwrap(); + let mut u = Ui::new(s); + let quad = |u: &Ui, i| crate::canvas::node_quad(&u.app, u.app.session.doc().unwrap(), i).unwrap(); + let shapes = flat(&u.frame_output(vec![]).shapes); + let sq = quad(&u, slice); + assert!(!outlined(&shapes, quad(&u, inner)), "off by default"); + assert_eq!(dashes_along(&shapes, sq[0], sq[1]), 0, "slices hidden by default"); + u.app.run("view.frameOutlines", json!({})); + u.app.run("view.slices", json!({})); + let shapes = flat(&u.frame_output(vec![]).shapes); + for frame in [f, inner] { + assert!(outlined(&shapes, quad(&u, frame)), "every frame is outlined"); + } + assert!(dashes_along(&shapes, sq[0], sq[1]) > 3, "the slice shows as a dashed rectangle"); + u.app.run("view.frameOutlines", json!({"on": false})); + u.app.run("view.slices", json!({"on": false})); + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(!outlined(&shapes, quad(&u, inner)) && dashes_along(&shapes, sq[0], sq[1]) == 0, "both turn off again"); +} + +/// A job a few hundred milliseconds of rendering big. +fn big_job(doc: &Arc, page: figcraft_doc::NodeId, scale: f64) -> crate::render_worker::Job { + crate::render_worker::Job { + doc: doc.clone(), + page, + view: figcraft_render::View { origin: (0.0, 0.0), scale, width: 2400, height: 2400 }, + outline: false, + outlined: vec![], + skip: vec![], + solo: None, + } +} + +#[test] +fn a_job_queued_behind_a_finished_one_stays_in_flight() { + let ctx = egui::Context::default(); + let wakes = Arc::new(AtomicUsize::new(0)); + let w = wakes.clone(); + ctx.set_request_repaint_callback(move |_| { + w.fetch_add(1, Ordering::SeqCst); + }); + let mut s = Session::new(); + for k in 0..40 { + s.execute("shape.ellipse", &json!({"x": k * 50, "y": k * 50, "width": 400, "height": 400})).unwrap(); + } + let ds = s.ds().unwrap(); + let mut worker = crate::render_worker::Worker::new(ctx.clone()); + worker.submit(big_job(&ds.doc, ds.page, 1.0)); + let start = Instant::now(); + while wakes.load(Ordering::SeqCst) == 0 { + assert!(start.elapsed() < Duration::from_secs(60), "the first render never finished"); + std::thread::sleep(Duration::from_millis(2)); + } + // As in a canvas frame: a new job goes out, then the finished one is picked up. + worker.submit(big_job(&ds.doc, ds.page, 0.9)); + assert!(worker.poll().is_some(), "the first image"); + // Not in flight only once the second is done: its image is then waiting. + assert!(worker.in_flight() || worker.poll().is_some(), "the second job counts until its image is there"); +} diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 19f0fa17..2c71f1cd 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -44,20 +44,14 @@ impl Harness { /// row asking for no repaint sooner than [`IDLE`]. Returns the last frame's delay; fails with /// what kept asking for frames. fn settle(&mut self) -> Duration { - let start = Instant::now(); let mut quiet = 0; for _ in 0..2000 { let delay = self.frame(vec![]); - let rendering = self.app.worker.as_ref().is_some_and(|w| w.in_flight); - if rendering { - // The render thread wakes the UI when it's done. - std::thread::sleep(Duration::from_millis(2)); - } + let rendering = wait_for_render(&self.app); quiet = if delay >= IDLE && !rendering { quiet + 1 } else { 0 }; if quiet >= 3 { return delay; } - assert!(start.elapsed() < Duration::from_secs(20), "the UI never settled"); } panic!("still asking for frames after 2000 with no input: {:?}", self.ctx.repaint_causes()); } @@ -78,6 +72,19 @@ impl Harness { } } +/// Waits (without drawing frames, however slow the machine) until the render thread is done with +/// every job and has woken the UI for it; returns whether one was in flight. +pub(crate) fn wait_for_render(app: &FigApp) -> bool { + let start = Instant::now(); + let mut waited = false; + while app.worker.as_ref().is_some_and(crate::render_worker::Worker::in_flight) { + assert!(start.elapsed() < Duration::from_secs(120), "the render thread never finished"); + std::thread::sleep(Duration::from_millis(2)); + waited = true; + } + waited +} + /// A page with a rectangle, an ellipse and a text layer; returns the text's id. fn document() -> (Harness, Value) { let mut s = Session::new(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ac5d373e..2cad7793 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -63,6 +63,8 @@ mod branch_ui_tests; #[cfg(test)] mod canvas_drag_tests; #[cfg(test)] +mod canvas_fixes_tests; +#[cfg(test)] mod comments_tests; #[cfg(test)] mod dev_mode_tests; diff --git a/crates/ui-egui/src/live_tests.rs b/crates/ui-egui/src/live_tests.rs index 5df48845..78295f4e 100644 --- a/crates/ui-egui/src/live_tests.rs +++ b/crates/ui-egui/src/live_tests.rs @@ -148,10 +148,7 @@ impl Harness { pub(crate) fn goes_quiet(&mut self) -> bool { let mut quiet = 0; for _ in 0..200 { - let rendering = self.app.worker.as_ref().is_some_and(|w| w.in_flight); - if rendering { - std::thread::sleep(Duration::from_millis(2)); - } + let rendering = crate::idle_tests::wait_for_render(&self.app); let (_, d) = self.frame(vec![]); quiet = if d >= Duration::from_secs(1) && !rendering { quiet + 1 } else { 0 }; if quiet >= 3 { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 23f62eeb..30529e67 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -515,13 +515,14 @@ fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) let p = app.to_page(pos); let Ok(ds) = app.session.ds() else { return false }; let tol = 2.0 / ds.view.zoom.max(1e-6); - let layers: Vec<(figcraft_doc::NodeId, usize, String)> = figcraft_doc::geometry::layers_at(&ds.doc, ds.page, p, tol) - .into_iter() - .take(40) - .filter_map(|(id, depth)| { - ds.doc.node(id).map(|n| (id, depth, if n.name.is_empty() { n.kind.type_name().to_lowercase() } else { n.name.clone() })) - }) - .collect(); + let layers: Vec<(figcraft_doc::NodeId, usize, String)> = + figcraft_doc::geometry::layers_at(&ds.doc, ds.page, p, tol, Some(&figcraft_engine::booleans::result_outline(&ds.doc, &ds.selection))) + .into_iter() + .take(40) + .filter_map(|(id, depth)| { + ds.doc.node(id).map(|n| (id, depth, if n.name.is_empty() { n.kind.type_name().to_lowercase() } else { n.name.clone() })) + }) + .collect(); if layers.is_empty() { return false; } diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index 3111ea9d..3ec7a1b4 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -66,22 +66,28 @@ fn run(job: &Job, retained: &mut Retained) -> Option { #[cfg(not(target_arch = "wasm32"))] pub struct Worker { - tx: std::sync::mpsc::Sender, + tx: std::sync::mpsc::Sender<(u64, Job)>, rx: std::sync::mpsc::Receiver, - pub in_flight: bool, + /// Jobs submitted so far. + submitted: u64, + /// The newest job the thread is done with: rendered (or skipped for a newer one) and the UI + /// woken for it. + finished: Arc, } #[cfg(not(target_arch = "wasm32"))] impl Worker { pub fn new(ctx: egui::Context) -> Self { - let (tx, jobs) = std::sync::mpsc::channel::(); + let (tx, jobs) = std::sync::mpsc::channel::<(u64, Job)>(); let (done_tx, rx) = std::sync::mpsc::channel::(); + let finished = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let done_with = finished.clone(); let spawned = std::thread::Builder::new().name("figcraft-render".into()).spawn(move || { let mut retained = Retained::default(); - while let Ok(mut job) = jobs.recv() { + while let Ok((mut seq, mut job)) = jobs.recv() { // Skip to the newest queued job. while let Ok(newer) = jobs.try_recv() { - job = newer; + (seq, job) = newer; } let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&job, &mut retained))); let done = match res { @@ -98,28 +104,32 @@ impl Worker { break; } ctx.request_repaint(); + done_with.store(seq, std::sync::atomic::Ordering::Release); } }); if let Err(e) = spawned { log::error!("render thread: {e}"); } - Self { tx, rx, in_flight: false } + Self { tx, rx, submitted: 0, finished } } pub fn submit(&mut self, job: Job) { - if self.tx.send(job).is_ok() { - self.in_flight = true; + if self.tx.send((self.submitted + 1, job)).is_ok() { + self.submitted += 1; } } + /// Whether a submitted job is still rendering or hasn't woken the UI yet (a finished image + /// may still wait for [`Self::poll`]). + pub fn in_flight(&self) -> bool { + self.finished.load(std::sync::atomic::Ordering::Acquire) < self.submitted + } + pub fn poll(&mut self) -> Option { let mut last = None; while let Ok(d) = self.rx.try_recv() { last = Some(d); } - if last.is_some() { - self.in_flight = false; - } last } } @@ -128,21 +138,21 @@ impl Worker { pub struct Worker { pending: Option, retained: Retained, - pub in_flight: bool, } #[cfg(target_arch = "wasm32")] impl Worker { pub fn new(_ctx: egui::Context) -> Self { - Self { pending: None, retained: Retained::default(), in_flight: false } + Self { pending: None, retained: Retained::default() } } pub fn submit(&mut self, job: Job) { self.pending = Some(job); - self.in_flight = true; + } + pub fn in_flight(&self) -> bool { + self.pending.is_some() } pub fn poll(&mut self) -> Option { let job = self.pending.take()?; - self.in_flight = false; run(&job, &mut self.retained) } } diff --git a/docs/gaps.md b/docs/gaps.md index 564f5f71..8d9ec527 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -26,7 +26,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 10 | Team libraries: no team-wide lists or accounts; analytics are counts | Feature | 4–8 | [components-parity](components-parity.md) | | 11 | Spell check | Feature | 4–8 | [target-app-parity](target-app-parity.md) | | 12 | Video fills; HEIC images | File format | 5–10 | [file-format-parity](file-format-parity.md) | -| 13 | Boolean groups hit-tested by their operands (the rest of the vector tail landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) | +| 13 | Vector tail: none left (boolean hit testing on the result landed 2026-10-11) | Feature | — | [target-app-parity](target-app-parity.md) | | 14 | Variables: extended collections' tail | Feature | 1–2 | [components-parity](components-parity.md) | | 15 | Prototyping: media triggers and actions, gamepad | Feature | 3–6 | [prototyping-parity](prototyping-parity.md) | | 16 | Dev Mode: dev resources links, editor extension (code connect and Dev Mode plugins landed) | Feature | 1–3 | [dev-mode-parity](dev-mode-parity.md) | @@ -176,17 +176,18 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe video need decoders that are C libraries (libheif, FFmpeg) or each platform's media framework. - **Estimate:** 5–10 h (video playback in the presenter is most of it; it can reuse the GIF clock). -### 13. Boolean groups hit-tested by their operands -- **Landed (2026-10-11):** live boolean editing (operands selected by double-click or Cmd/Ctrl-click; +### 13. Vector tail: none left +- **Landed (2026-10-11):** boolean groups are clicked where their result is (a click in a + subtracted hole reaches what's behind; with the group or an operand selected its operands are + hit, so each can still be picked; "Select layer" lists the group only where its result is), live + boolean editing (operands selected by double-click or Cmd/Ctrl-click; moving, resizing or editing an operand's points redraws the result during the drag, with every operand outlined; per-operand operations from the boolean controls and `node.set`; a drag frame recombines only the operands from the edited one up), Design panel Arc fields (Start, Sweep, Ratio; Mixed across ellipses; bindable), the shape builder on strokes and open paths (strokes take part as their outlined areas), one-sided variable widths (Alt-drag a width handle; canvas, PNG, SVG, PDF and outline stroke). -- **Missing:** a click is tested against a boolean group's operands, not its result, so a click in - a subtracted hole still picks the group. -- **Estimate:** 1–3 h. +- **Estimate:** none left (vector QA with real files is part of gap 3). ### 14. Variables: extended collections' tail - **Landed:** extended collections (`library.extendCollection`: a library collection's modes and @@ -274,6 +275,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | | 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | | 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h | | 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | diff --git a/docs/mcp.md b/docs/mcp.md index 94f62882..8668e7a4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -137,7 +137,7 @@ Or in a project's `.mcp.json`: | `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window when attached. | | `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. | | `undo` / `redo` | Step through history. | -| `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd, pressure?, pressureTo?}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. `pressure` (0–1) is pen pressure; a drag goes from `pressure` to `pressureTo` (see Brushes and pen pressure). | +| `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd, pressure?, pressureTo?}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. A dragged layer drops into the frame or auto layout slot under the pointer, except one inside a group, a boolean group or an instance (outside its slots), which only moves. `pressure` (0–1) is pen pressure; a drag goes from `pressure` to `pressureTo` (see Brushes and pen pressure). | | `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (every default shortcut, tool keys, Escape, Delete, arrows, number keys for opacity; while editing text, the text shortcuts and then text editing keys). Keys are names like `A`, `0`, `Enter`, `ArrowLeft`, `=`, `/`, or egui's (`Plus`, `Questionmark`); `cmd` is Cmd on macOS and Ctrl elsewhere. | | `type_text {text, id?}` | Type into the text being edited, or replace the contents of text layer `id`. | | `inspect_ui {}` | Tool, view and status (and panels/timings when attached). | @@ -427,7 +427,9 @@ on any operand, or `booleanOperation` on an operand that isn't itself a boolean Operands stay editable layers: `select.children` (Enter) or a double-click selects them, `cmd: true` clicks reach them directly, and moving, resizing or editing an operand's points (`vector.edit.*`) updates the group's result on every pointer move, one undo step per gesture; a dragged operand -stays in its group. `bool.flatten` bakes the result. Only the operands from the edited one up are +stays in its group. Clicks and `select.layersAt` hit a boolean group where its result is (a click in +a subtracted hole reaches what's behind it); while the group or one of its operands is selected, its +operands are hit instead, so each can be picked. `bool.flatten` bakes the result. Only the operands from the edited one up are recombined during a drag (`figcraft_render::boolean`; timed by `cargo xtask bench`). ### Shape builder diff --git a/docs/roadmap.md b/docs/roadmap.md index 8e96e1ec..8e942974 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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 ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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 ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -50,7 +50,7 @@ blockers. | 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 | | 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, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11) | spell check, Figma Draw multi-edit, boolean hit testing on the result | 8–19 | +| 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 | | M5 Prototype & present | ~85% | media, gamepad, separate window | 5–9 | | M6 Interop & Dev Mode | ~55% (code connect and Dev Mode plugins landed 2026-10-11) | `.fig` import, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 37–75 | | M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 | @@ -72,6 +72,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | | 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | | 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h | | 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c4079a76..e58e91a3 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 (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 ~79%; ready ~47%; 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 (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 ~79%; ready ~47%; 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 @@ -97,7 +97,7 @@ 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 | 95% | 68% | Handles, measuring and snapping checked tool by tool against Figma; rotated groups and mixed selections | 6–10 | | 3 | Layers panel | 5 | 95% | 78% | Keyboard box selection, keyboard rename/context menu, long-list QA | 2–4 | -| 4 | Shapes & vector | 8 | 97% | 68% | Boolean groups hit-tested by their operands rather than their result; vector QA with real files (live boolean editing, arc fields, shape builder on strokes and open paths and one-sided widths landed) | 1–3 | +| 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 | | 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 | @@ -258,9 +258,9 @@ Hours in this narrative are the builders' estimates; the tables above supersede boolean editing (operands selected by double-click or Cmd/Ctrl-click; moving, resizing or editing an operand's points redraws the result during the drag with every operand outlined; per-operand operations from the boolean controls; only the operands from the edited one up are - recombined), the shape builder on strokes and open paths (strokes as their outlined areas), and + recombined; clicks hit a boolean group where its result is), the shape builder on strokes and open paths (strokes as their outlined areas), and one-sided variable widths (Alt-drag a width handle; canvas, exports and outline stroke). - *Left:* boolean groups are hit-tested by their operands rather than their result. 1–3 h. + *Left:* vector QA with real files. 1–2 h. 2. **Text editing depth**: *landed:* OpenType features panel, small caps, lists, title case, truncation/max lines, font picker with previews and recents, word/line selection, IME commit, inline IME composition (underlined at the caret, reflowing, candidate window at the caret; the @@ -578,6 +578,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | | 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | | 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h | | 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1f37951d..3e554a80 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 (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 (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: @@ -56,7 +56,7 @@ interaction feel not yet compared. | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only | | 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: double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | hit-tested by the operands, not the result | +| 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 | | Eyedropper (I) | done | | | Comment (C), Shift+C to show/hide | done | local only | @@ -71,7 +71,9 @@ interaction feel not yet compared. | Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | | | Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | | | Frame labels (rotated, right-to-left), size badge | done | | -| Outline mode | done | Outlines, outlines for the selection, mask outlines | +| Outline mode | done | Outlines, outlines for the selection, mask outlines, frame outlines | +| Slices shown on the canvas (View ▸ Slices) | done | dashed rectangles | +| Dragging a layer inside a group keeps it in the group | done | frames and auto layout take other drops | | Layout grids (Ctrl+G) | done | | | Gradient and padding/gap handles on the canvas | done | | | Hover outline, selection colours, handle sizes | done | measured from UI3 screenshots | @@ -92,6 +94,7 @@ interaction feel not yet compared. | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-11 | minor | Outline mode done: outlines for the selection (the selected layers draw as outlines, the rest normally) and mask outlines | | 2026-10-11 | minor | Design panel arc fields, live boolean editing (operands on the canvas, live results, per-operand operations), shape builder on strokes and open paths, one-sided widths (Alt-drag a width handle) | | 2026-10-10 | major | Created from the ROADMAP's look-and-feel and interaction items, the keymap and the canvas code |