From df2309f5b90d39db9796e819b96ee44983371423 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 14:04:17 +0700 Subject: [PATCH] M1: measuring, scale tool handles and selection depth (#77) Measuring (Alt/Option): distances from the selection to the hovered layer, its parent (over the selection itself or empty space) and ruler guides; Cmd+Alt for nested layers; containment both ways, overlaps between matching edges, gaps with dashed extension lines; rotated layers and several selected layers by their bounds; the measured box outlined. Alt while dragging shows live distances to the neighbours (or the parent's edges); Shift+Alt reads out an auto layout frame's padding and gaps. Labels are placed so they never overlap each other or the size label and stay the same size at every zoom. Commands: measure.distances, measure.padding. Scale tool (K) canvas handles: one factor about the Scale panel's anchor (tool.scaleAnchor, shared by the panel and the canvas; the opposite handle when the anchor is on the side being dragged), Alt from the centre, flips past the pivot, rotated layers and lines along their own axes, one undo step per drag, a live factor in the size label and the pivot marked. scale_node now also scales min/max sizes, layout grids, guides, fixed grid tracks, tiled images, paragraph indent and run letter spacing. Selection: Cmd/Ctrl-marquee selects nested layers, Shift-marquee toggles, the context menu's Select layer lists every layer under the click (select.layersAt), outlines are purple for every layer inside components and instances, hover outlines show with the Scale tool too. Fixed marquees selecting layers that only overlapped the marquee on one axis. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 10 +- crates/doc/src/geometry.rs | 74 +++- crates/engine/src/cmd/arrange.rs | 48 ++- crates/engine/src/cmd/edit.rs | 18 + crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/view.rs | 15 + crates/engine/src/distance.rs | 386 +++++++++++++++++++++ crates/engine/src/lib.rs | 7 + crates/engine/src/measure_tests.rs | 251 ++++++++++++++ crates/engine/src/scale_tool_tests.rs | 242 +++++++++++++ crates/engine/src/selection_depth_tests.rs | 125 +++++++ crates/engine/src/snap.rs | 51 +-- crates/engine/src/tools.rs | 249 ++++++++++--- crates/ui-egui/src/canvas.rs | 104 +++--- crates/ui-egui/src/control.rs | 13 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/measure_overlay.rs | 135 +++++++ crates/ui-egui/src/menus.rs | 86 ++++- crates/ui-egui/src/scale_panel.rs | 13 +- docs/control-protocol.md | 7 +- docs/mcp.md | 25 ++ docs/parity.md | 2 +- 22 files changed, 1682 insertions(+), 181 deletions(-) create mode 100644 crates/engine/src/distance.rs create mode 100644 crates/engine/src/measure_tests.rs create mode 100644 crates/engine/src/scale_tool_tests.rs create mode 100644 crates/engine/src/selection_depth_tests.rs create mode 100644 crates/ui-egui/src/measure_overlay.rs diff --git a/ROADMAP.md b/ROADMAP.md index 991a2eec..d4c94d09 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -210,8 +210,14 @@ dimension measured side by side. skrifa outlines, font fallback), aligned strokes with caching. - **Engine:** pointer tools (move/marquee/deep select/resize/rotate/Alt-duplicate with smart-guide snapping, frame/section/slice/shapes/line/arrow with Shift/Alt, text, pen, pencil, hand, - eyedropper), text editing (caret, selection, words, lines), auto layout reorder by dragging, - drop into frames, align/distribute/tidy, z-order, flip/rotate, scale tool, booleans, flatten, + eyedropper), Cmd/Ctrl-click and Cmd/Ctrl-marquee into nested layers, Shift-marquee toggling, + Select layer (every layer under the click), measuring (Alt-hover distances from the selection to + layers, parents and ruler guides, Cmd+Alt for nested layers, extension lines, one box inside the + other, overlaps, live distances to the neighbours while Alt-dragging, Shift+Alt for auto layout + padding and gaps; labels that never overlap at any zoom), text editing (caret, selection, words, + lines), auto layout reorder by dragging, drop into frames, align/distribute/tidy, z-order, + flip/rotate, the scale tool (panel, and canvas handles that scale everything about its anchor + point or from the centre with Alt, with a live factor, one undo step per drag), booleans, flatten, outline stroke, components (create, instances, sync with overrides, reset, detach, push changes, variants, properties, slots), styles and variables, prototype commands, find/replace, batch rename, copy/paste (internal + SVG/text), file new/open/save/import/export (native `.figcraft`, SVG diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 91327358..3372a7da 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -132,6 +132,44 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize) -> Opt None } +/// 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)>) { + let Some(n) = doc.node(id) else { return }; + if !n.visible || n.locked || depth > 512 { + return; + } + let clips = n.clip && n.kind.is_frame_like(); + if clips && !hits_node_itself(doc, id, n, pt, 0.0) { + return; + } + 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() { + out.push((id, depth)); + } + return; + } + for c in n.children.iter().rev() { + rec(doc, *c, pt, tol, depth + 1, out); + } + if out.len() > at || hits_node_itself(doc, id, n, pt, tol) { + out.insert(at, (id, depth)); + } + } + 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); + } + } + out +} + /// Resolve a clicked leaf to what a plain click selects. /// /// Figma's rules: top-level frames behave like artboards, so a click selects their direct child; @@ -183,17 +221,39 @@ pub fn select_target(doc: &Document, leaf: NodeId, context: &[NodeId], deep: boo /// children of top-level frames when the marquee starts inside a frame (`within`). pub fn marquee(doc: &Document, page: NodeId, rect: Rect, within: Option) -> Vec { let parent = within.unwrap_or(page); - let Some(p) = doc.node(parent) else { return vec![] }; + doc.node(parent).map(|p| marquee_children(doc, p, rect)).unwrap_or_default() +} + +/// Nodes on `page` a Cmd/Ctrl marquee selects: the innermost layers whose bounds touch `rect` +/// (frames, groups and instances are looked into; a container none of whose children are touched +/// counts itself; boolean groups count as one shape). +pub fn marquee_deep(doc: &Document, page: NodeId, rect: Rect) -> Vec { + fn walk(doc: &Document, parent: NodeId, rect: Rect, depth: usize, out: &mut Vec) { + let Some(p) = doc.node(parent) else { return }; + for c in marquee_children(doc, p, rect) { + let before = out.len(); + let opens = doc.node(c).is_some_and(|n| !n.children.is_empty() && !matches!(n.kind, NodeKind::BooleanOp { .. })); + if opens && depth < 256 { + walk(doc, c, rect, depth + 1, out); + } + if out.len() == before { + out.push(c); + } + } + } + let mut out = Vec::new(); + walk(doc, page, rect, 0, &mut out); + out +} + +/// The visible, unlocked children of `p` whose world bounds touch `rect` (on both axes: lines and +/// other flat layers too). +fn marquee_children(doc: &Document, p: &Node, rect: Rect) -> Vec { p.children .iter() .copied() .filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.locked)) - .filter(|c| { - doc.world_bounds(*c).is_some_and(|b| { - let inter = b.intersect(rect); - inter.width() > 0.0 || inter.height() > 0.0 || (b.width() == 0.0 || b.height() == 0.0) && rect.contains(b.origin()) - }) - }) + .filter(|c| doc.world_bounds(*c).is_some_and(|b| b.x0 <= rect.x1 && b.x1 >= rect.x0 && b.y0 <= rect.y1 && b.y1 >= rect.y0)) .collect() } diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 71cd4ef5..ad0f97c4 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -330,12 +330,37 @@ fn rotate_by(s: &mut Session, p: &Value, deg: f64) -> Result { ok() } -/// Scale nodes uniformly including strokes, effects, radii and text sizes (the Scale tool). +/// Scale a node and everything in it uniformly (the Scale tool): sizes and positions (whatever +/// the children's constraints), strokes, effects, radii, auto layout spacing, min/max sizes, +/// layout grids, ruler guides, tiled images and text sizes. pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> { let k = if k.is_finite() && k > 0.0 { k } else { return Ok(()) }; let sub = doc.subtree(id); + let tiles = |paints: &mut Vec| { + for p in paints { + if let figcraft_color::PaintKind::Image(img) = &mut p.kind + && img.scale_mode == figcraft_color::ImageScaleMode::Tile + { + img.scale *= k; + } + } + }; for (depth, i) in sub.iter().enumerate() { let n = doc.get_mut(*i)?; + tiles(&mut n.fills); + tiles(&mut n.strokes); + let cl = &mut n.child_layout; + for v in [&mut cl.min_w, &mut cl.max_w, &mut cl.min_h, &mut cl.max_h].into_iter().flatten() { + *v *= k; + } + for g in &mut n.grids { + g.size *= k; + g.gutter *= k; + g.offset *= k; + } + for g in &mut n.guides { + g.offset *= k; + } n.stroke.weight *= k; if let Some(sides) = n.stroke.sides.as_mut() { for v in sides.iter_mut() { @@ -373,6 +398,9 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> { l.gap *= k; l.cross_gap *= k; l.padding = l.padding.map(|x| x * k); + for t in l.column_tracks.iter_mut().chain(l.row_tracks.iter_mut()).filter(|t| t.sizing == Sizing::Fixed) { + t.value *= k; + } } if let NodeKind::Text(t) = &mut n.kind { t.style.font_size *= k; @@ -387,8 +415,12 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> { if let LineHeight::Pixels(v) = &mut r.style.line_height { *v *= k; } + if let LetterSpacing::Pixels(v) = &mut r.style.letter_spacing { + *v *= k; + } } t.paragraph_spacing *= k; + t.paragraph_indent *= k; } if let NodeKind::Vector { network } = &mut n.kind { network.scale(k, k); @@ -427,18 +459,8 @@ fn scale_cmd(s: &mut Session, p: &Value) -> Result { pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result { let ids = req_targets("transform.resize", s, p)?; - let anchor = string(p, "anchor").unwrap_or_else(|| "tl".into()); - let (ax, ay) = match anchor.as_str() { - "t" => (0.5, 0.0), - "tr" => (1.0, 0.0), - "l" => (0.0, 0.5), - "c" => (0.5, 0.5), - "r" => (1.0, 0.5), - "bl" => (0.0, 1.0), - "b" => (0.5, 1.0), - "br" => (1.0, 1.0), - _ => (0.0, 0.0), - }; + let (col, row) = string(p, "anchor").and_then(|a| crate::tools::anchor_of(&a)).unwrap_or((0, 0)); + let (ax, ay) = (f64::from(col) / 2.0, f64::from(row) / 2.0); let doc = s.doc_mut()?; for i in ids { let n = doc.get(i)?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index b581db53..75c9833e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -66,6 +66,13 @@ pub fn specs() -> Vec { s.set_selection(sel); Ok(json!({"selection": s.selection()})) }), + spec!( + "select.layersAt", + "Layers at a point", + "{x, y (page coordinates)} → {layers: [{id, name, type, depth}]} (every layer under the point, front-most first; what the context menu's Select layer lists)", + false, + layers_at + ), spec!("select.all", "Select all", "{}", false, select_all), spec!("select.none", "Select none", "{}", false, |s, _| { s.set_selection(vec![]); @@ -644,6 +651,17 @@ fn paste_props(s: &mut Session, p: &Value) -> Result { ok() } +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) + .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(); + Ok(json!({"layers": layers})) +} + fn select_all(s: &mut Session, _p: &Value) -> Result { let doc = s.doc()?; let sel = s.selection(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 72d02aac..1d2ed547 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -84,6 +84,7 @@ fn registry() -> &'static Registry { list.extend(crate::shapebuilder::specs()); list.extend(crate::codegen::specs()); list.extend(crate::devinspect::specs()); + list.extend(crate::distance::specs()); let mut index = HashMap::new(); for (i, s) in list.iter().enumerate() { if index.insert(s.id, i).is_some() { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 099d8d81..a45428aa 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -212,6 +212,21 @@ pub fn specs() -> Vec { tool(s, t) } ), + spec!( + "tool.scaleAnchor", + "Scale anchor", + "{anchor?: tl|t|tr|l|c|r|bl|b|br} (the point of the selection that stays put when the Scale tool scales; omit to read it)", + false, + |s, p| { + if let Some(a) = string(p, "anchor") { + s.tool.scale_anchor = crate::tools::anchor_of(&a).ok_or_else(|| bad("tool.scaleAnchor", format!("unknown anchor `{a}`")))?; + if let Ok(ds) = s.ds_mut() { + ds.revision += 1; + } + } + Ok(json!({"anchor": crate::tools::anchor_name(s.tool.scale_anchor)})) + } + ), spec!("tool.move", "Move", "{}", false, |s, _| tool(s, ToolId::Move)), spec!("tool.scale", "Scale", "{}", false, |s, _| tool(s, ToolId::Scale)), spec!("tool.hand", "Hand", "{}", false, |s, _| tool(s, ToolId::Hand)), diff --git a/crates/engine/src/distance.rs b/crates/engine/src/distance.rs new file mode 100644 index 00000000..47f82e96 --- /dev/null +++ b/crates/engine/src/distance.rs @@ -0,0 +1,386 @@ +//! Measuring: red distance lines with value labels between bounding boxes in page space (rotated +//! layers by their bounds, several selected layers by their union). +//! +//! - Alt-hover: from the selection to the layer under the pointer (Cmd+Alt: the nested layer), +//! to its parent (over the selection itself or empty space inside the parent) or to a ruler +//! guide. One box inside the other measures to each edge of the outer one; boxes apart measure +//! the gaps, with dashed extension lines to the other box when the line passes beside it; boxes +//! that overlap measure between their matching edges. +//! - Alt while dragging layers: live distances to the nearest neighbours on each side within the +//! parent (to the parent's edges where there is none). +//! - Shift+Alt over an auto layout frame: its padding and the gaps between its children. +//! +//! The canvas draws the lines and places the labels so they never overlap (see the UI crate). + +use figcraft_doc::{Document, NodeId, NodeKind}; +use figcraft_geom::{Point, Rect}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::{id, targets}; +use crate::snap::Guide; +use crate::{Result, Session, bad, spec}; + +/// Lines shorter than this (page units) aren't drawn. +const EPS: f64 = 1e-6; +/// The most layers looked at for neighbours and gaps. +const MAX_BOXES: usize = 512; + +/// A distance as shown on a label: up to two decimals, no trailing zeros. +pub fn label(d: f64) -> String { + let v = (d.abs() * 100.0).round() / 100.0; + if v.fract() == 0.0 && v < 1e15 { format!("{}", v as i64) } else { format!("{v}") } +} + +/// A labelled distance line, unless it has no length. +fn measured(out: &mut Vec, a: Point, b: Point) { + let d = (b - a).hypot(); + if d > EPS && d.is_finite() { + out.push(Guide { label: Some(label(d)), ..Guide::new(a, b) }); + } +} + +/// A dashed extension line (no label), unless it has no length. +fn extension(out: &mut Vec, a: Point, b: Point) { + if (b - a).hypot() > EPS { + out.push(Guide { dashed: true, ..Guide::new(a, b) }); + } +} + +fn contains(outer: Rect, inner: Rect) -> bool { + outer.x0 <= inner.x0 + EPS && outer.y0 <= inner.y0 + EPS && outer.x1 >= inner.x1 - EPS && outer.y1 >= inner.y1 - EPS +} + +/// The middle of the overlap of two spans, if they overlap. +fn overlap_mid(a0: f64, a1: f64, b0: f64, b1: f64) -> Option { + let (lo, hi) = (a0.max(b0), a1.min(b1)); + (lo <= hi).then_some((lo + hi) / 2.0) +} + +/// Distance lines from box `a` (the selection) to box `b`. +pub fn between(a: Rect, b: Rect) -> Vec { + let mut out = Vec::new(); + if !(a.is_finite() && b.is_finite()) { + return out; + } + if contains(b, a) { + inside(&mut out, a, b); + return out; + } + if contains(a, b) { + inside(&mut out, b, a); + return out; + } + let ox = overlap_mid(a.x0, a.x1, b.x0, b.x1); + let oy = overlap_mid(a.y0, a.y1, b.y0, b.y1); + let overlapping = a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1; + if let (true, Some(mx), Some(my)) = (overlapping, ox, oy) { + // Overlapping: between matching edges. + measured(&mut out, Point::new(a.x0, my), Point::new(b.x0, my)); + measured(&mut out, Point::new(a.x1, my), Point::new(b.x1, my)); + measured(&mut out, Point::new(mx, a.y0), Point::new(mx, b.y0)); + measured(&mut out, Point::new(mx, a.y1), Point::new(mx, b.y1)); + return out; + } + let c = a.center(); + // Horizontal gap: along the shared rows, or from the middle of `a` with an extension to `b`. + let gap_x = if b.x0 >= a.x1 { + Some((a.x1, b.x0)) + } else if b.x1 <= a.x0 { + Some((a.x0, b.x1)) + } else { + None + }; + if let Some((from, to)) = gap_x { + let y = oy.unwrap_or(c.y); + measured(&mut out, Point::new(from, y), Point::new(to, y)); + if oy.is_none() { + extension(&mut out, Point::new(to, y), Point::new(to, if y < b.y0 { b.y0 } else { b.y1 })); + } + } + let gap_y = if b.y0 >= a.y1 { + Some((a.y1, b.y0)) + } else if b.y1 <= a.y0 { + Some((a.y0, b.y1)) + } else { + None + }; + if let Some((from, to)) = gap_y { + let x = ox.unwrap_or(c.x); + measured(&mut out, Point::new(x, from), Point::new(x, to)); + if ox.is_none() { + extension(&mut out, Point::new(x, to), Point::new(if x < b.x0 { b.x0 } else { b.x1 }, to)); + } + } + out +} + +/// From `inner` to each edge of `outer`, along `inner`'s centre lines. +fn inside(out: &mut Vec, inner: Rect, outer: Rect) { + let c = inner.center(); + measured(out, Point::new(outer.x0, c.y), Point::new(inner.x0, c.y)); + measured(out, Point::new(inner.x1, c.y), Point::new(outer.x1, c.y)); + measured(out, Point::new(c.x, outer.y0), Point::new(c.x, inner.y0)); + measured(out, Point::new(c.x, inner.y1), Point::new(c.x, outer.y1)); +} + +/// Distance lines from box `a` to a ruler guide (`vertical` at x = `at`, else at y = `at`): to +/// the nearer side, or both sides when the guide crosses the box. +pub fn to_guide(a: Rect, vertical: bool, at: f64) -> Vec { + let mut out = Vec::new(); + if !(a.is_finite() && at.is_finite()) { + return out; + } + let c = a.center(); + let (lo, hi) = if vertical { (a.x0, a.x1) } else { (a.y0, a.y1) }; + let pt = |v: f64| if vertical { Point::new(v, c.y) } else { Point::new(c.x, v) }; + if at >= hi { + measured(&mut out, pt(hi), pt(at)); + } else if at <= lo { + measured(&mut out, pt(at), pt(lo)); + } else { + measured(&mut out, pt(lo), pt(at)); + measured(&mut out, pt(at), pt(hi)); + } + out +} + +/// The ruler guides near page point `p` (within `tol`): the page's and those of the frames +/// around `around` (axis-aligned frames only), as (vertical, page offset). +pub fn guide_at(doc: &Document, page: NodeId, around: &[NodeId], p: Point, tol: f64) -> Option<(bool, f64)> { + let mut lines: Vec<(bool, f64)> = Vec::new(); + if let Some(n) = doc.node(page) { + lines.extend(n.guides().iter().map(|g| (g.vertical, g.offset))); + } + let mut frames: Vec = Vec::new(); + for a in around { + for f in doc.ancestors(*a) { + if f != page && !frames.contains(&f) { + frames.push(f); + } + } + } + for f in frames { + let Some(n) = doc.node(f).filter(|n| !n.guides.is_empty()) else { continue }; + let w = doc.world_transform(f); + let c = w.as_coeffs(); + if c[1].abs() > EPS || c[2].abs() > EPS { + continue; + } + for g in &n.guides { + let q = w * Point::new(g.offset, g.offset); + lines.push((g.vertical, if g.vertical { q.x } else { q.y })); + } + } + lines + .into_iter() + .filter(|(v, at)| at.is_finite() && (if *v { p.x } else { p.y } - at).abs() <= tol) + .min_by(|a, b| (if a.0 { p.x } else { p.y } - a.1).abs().total_cmp(&(if b.0 { p.x } else { p.y } - b.1).abs())) +} + +/// The visible layers in `parent` other than `moving` (and not inside them), by their bounds. +fn sibling_boxes(doc: &Document, parent: NodeId, moving: &[NodeId]) -> Vec { + let Some(p) = doc.node(parent) else { return Vec::new() }; + p.children + .iter() + .filter(|c| !moving.contains(c) && doc.node(**c).is_some_and(|n| n.visible)) + .take(MAX_BOXES) + .filter_map(|c| doc.world_bounds(*c)) + .filter(|b| b.is_finite()) + .collect() +} + +/// Live distances while dragging `moving` (bounds `a`): to the nearest sibling on each side whose +/// span faces `a`, or to the parent's edge on sides without one (the page has no edges). +pub fn neighbours(doc: &Document, page: NodeId, moving: &[NodeId], a: Rect) -> Vec { + let mut out = Vec::new(); + if !a.is_finite() { + return out; + } + let parent = moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent).unwrap_or(page); + let boxes = sibling_boxes(doc, parent, moving); + let frame = (parent != page).then(|| doc.world_bounds(parent)).flatten().filter(|f| f.is_finite()); + let c = a.center(); + // For each side: the nearest facing box edge (its position and the shared span's middle). + let nearest = |side: u8| -> Option<(f64, f64)> { + boxes + .iter() + .filter_map(|b| { + let (edge, mid, dist) = match side { + 0 => (b.x1, overlap_mid(a.y0, a.y1, b.y0, b.y1)?, a.x0 - b.x1), + 1 => (b.x0, overlap_mid(a.y0, a.y1, b.y0, b.y1)?, b.x0 - a.x1), + 2 => (b.y1, overlap_mid(a.x0, a.x1, b.x0, b.x1)?, a.y0 - b.y1), + _ => (b.y0, overlap_mid(a.x0, a.x1, b.x0, b.x1)?, b.y0 - a.y1), + }; + (dist >= -EPS).then_some((edge, mid, dist)) + }) + .min_by(|x, y| x.2.total_cmp(&y.2)) + .map(|(edge, mid, _)| (edge, mid)) + }; + for side in 0..4u8 { + let hit = nearest(side).or_else(|| { + let f = frame.filter(|f| contains(*f, a))?; + Some(match side { + 0 => (f.x0, c.y), + 1 => (f.x1, c.y), + 2 => (f.y0, c.x), + _ => (f.y1, c.x), + }) + }); + let Some((edge, mid)) = hit else { continue }; + let (p, q) = match side { + 0 => (Point::new(edge, mid), Point::new(a.x0, mid)), + 1 => (Point::new(a.x1, mid), Point::new(edge, mid)), + 2 => (Point::new(mid, edge), Point::new(mid, a.y0)), + _ => (Point::new(mid, a.y1), Point::new(mid, edge)), + }; + measured(&mut out, p, q); + } + out +} + +/// The auto layout frame Shift+Alt reads out for `target`: itself, or its parent. +pub fn padding_frame(doc: &Document, target: NodeId) -> Option { + let has_layout = |i: NodeId| doc.node(i).is_some_and(|n| n.layout.is_some()); + if has_layout(target) { + return Some(target); + } + doc.node(target).and_then(|n| n.parent).filter(|p| has_layout(*p)) +} + +/// An auto layout frame's padding (from each edge, across the middle of the content) and the +/// gaps between neighbouring children in its flow, in page space (rotated frames too). +pub fn padding(doc: &Document, frame: NodeId) -> Vec { + let mut out = Vec::new(); + let Some(n) = doc.node(frame) else { return out }; + let Some(l) = n.layout.as_ref() else { return out }; + let w = doc.world_transform(frame); + // Flow children in the frame's own space. + let kids: Vec = n + .children + .iter() + .filter_map(|c| doc.node(*c)) + .filter(|c| c.visible && !c.child_layout.absolute && !matches!(c.kind, NodeKind::Slice)) + .take(MAX_BOXES) + .map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())) + .filter(|r| r.is_finite()) + .collect(); + let content = kids.iter().copied().reduce(|a, b| a.union(b)); + let (cx, cy) = content.map_or((n.width / 2.0, n.height / 2.0), |r| (r.center().x, r.center().y)); + let [top, right, bottom, left] = l.padding.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }); + let mut push = |a: Point, b: Point| measured(&mut out, w * a, w * b); + push(Point::new(0.0, cy), Point::new(left, cy)); + push(Point::new(n.width - right, cy), Point::new(n.width, cy)); + push(Point::new(cx, 0.0), Point::new(cx, top)); + push(Point::new(cx, n.height - bottom), Point::new(cx, n.height)); + for (i, a) in kids.iter().enumerate() { + // The nearest child to the right sharing rows, and below sharing columns. + let right = kids + .iter() + .enumerate() + .filter(|(j, b)| *j != i && b.x0 >= a.x1 - EPS && overlap_mid(a.y0, a.y1, b.y0, b.y1).is_some()) + .min_by(|x, y| x.1.x0.total_cmp(&y.1.x0)); + if let Some((_, b)) = right + && let Some(my) = overlap_mid(a.y0, a.y1, b.y0, b.y1) + { + push(Point::new(a.x1, my), Point::new(b.x0, my)); + } + let below = kids + .iter() + .enumerate() + .filter(|(j, b)| *j != i && b.y0 >= a.y1 - EPS && overlap_mid(a.x0, a.x1, b.x0, b.x1).is_some()) + .min_by(|x, y| x.1.y0.total_cmp(&y.1.y0)); + if let Some((_, b)) = below + && let Some(mx) = overlap_mid(a.x0, a.x1, b.x0, b.x1) + { + push(Point::new(mx, a.y1), Point::new(mx, b.y0)); + } + } + out +} + +/// What Alt-hover measures to from a selection: a layer, a ruler guide, or an auto layout frame's +/// padding (Shift+Alt). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Hover { + Layer(NodeId), + Guide { vertical: bool, at: f64 }, + Padding(NodeId), +} + +/// The measurement lines for `hover` from the selection `sel` and the box to outline (the +/// measured-to layer or frame). +pub fn hover_lines(doc: &Document, page: NodeId, sel: &[NodeId], hover: Hover) -> (Vec, Option) { + match hover { + Hover::Padding(f) => (padding(doc, f), doc.world_bounds(f)), + Hover::Guide { vertical, at } => (doc.bounds_of(sel).map(|a| to_guide(a, vertical, at)).unwrap_or_default(), None), + Hover::Layer(t) => { + let Some(a) = doc.bounds_of(sel) else { return (Vec::new(), None) }; + // Over the selection itself: to its parent. + let t = if sel.contains(&t) { parent_of(doc, page, sel).unwrap_or(t) } else { t }; + if sel.contains(&t) { + return (Vec::new(), None); + } + match doc.world_bounds(t) { + Some(b) => (between(a, b), Some(b)), + None => (Vec::new(), None), + } + } + } +} + +/// The selection's parent when it isn't the page. +pub fn parent_of(doc: &Document, page: NodeId, sel: &[NodeId]) -> Option { + sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).filter(|p| *p != page && !doc.node(*p).is_some_and(|n| n.is_page())) +} + +fn lines_json(lines: &[Guide]) -> Value { + json!({"lines": lines}) +} + +fn measure_cmd(s: &mut Session, p: &Value) -> Result { + let doc = s.doc()?; + let page = s.page()?; + let sel = targets(s, p); + for i in &sel { + if !doc.contains(*i) { + return Err(crate::EngineError::NoNode(*i)); + } + } + if sel.is_empty() { + return Err(bad("measure.distances", "nothing is selected (pass `ids`)")); + } + let to = match id(p, "to") { + Some(t) if doc.contains(t) => t, + Some(t) => return Err(crate::EngineError::NoNode(t)), + None => parent_of(doc, page, &sel).ok_or_else(|| bad("measure.distances", "pass `to` (the selection is at the top level)"))?, + }; + let (lines, _) = hover_lines(doc, page, &sel, Hover::Layer(to)); + Ok(lines_json(&lines)) +} + +fn padding_cmd(s: &mut Session, p: &Value) -> Result { + let doc = s.doc()?; + let target = targets(s, p).first().copied().ok_or_else(|| bad("measure.padding", "pass `id` (an auto layout frame or one of its children)"))?; + let frame = padding_frame(doc, target).ok_or_else(|| bad("measure.padding", "not in an auto layout frame"))?; + Ok(json!({"frame": frame, "lines": padding(doc, frame)})) +} + +pub fn specs() -> Vec { + vec![ + spec!( + "measure.distances", + "Measure distances", + "{ids? (default selection), to? (layer id; default the parent)} → {lines: [{x0, y0, x1, y1, label, dashed}]} (page space; what Alt-hover shows)", + false, + measure_cmd + ), + spec!( + "measure.padding", + "Measure auto layout padding", + "{id? (an auto layout frame or a child; default the selection)} → {frame, lines} (padding and gaps; what Shift+Alt-hover shows)", + false, + padding_cmd + ), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c7a3d921..11b8fce3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -12,6 +12,7 @@ pub mod cmd; pub mod codegen; pub mod components; pub mod devinspect; +pub mod distance; pub mod drop; pub mod guard; pub mod inspect; @@ -929,6 +930,8 @@ mod library_variables_tests; #[cfg(test)] mod mask_tests; #[cfg(test)] +mod measure_tests; +#[cfg(test)] mod nested_override_tests; #[cfg(test)] mod paint_tests; @@ -945,8 +948,12 @@ mod proto_run_tests; #[cfg(test)] mod rotated_select_tests; #[cfg(test)] +mod scale_tool_tests; +#[cfg(test)] mod section_tests; #[cfg(test)] +mod selection_depth_tests; +#[cfg(test)] mod shapebuilder_tests; #[cfg(test)] mod slot_tests; diff --git a/crates/engine/src/measure_tests.rs b/crates/engine/src/measure_tests.rs new file mode 100644 index 00000000..2329e7be --- /dev/null +++ b/crates/engine/src/measure_tests.rs @@ -0,0 +1,251 @@ +//! Measuring: Alt-hover distances (layers, parents, guides, nested, rotated, several selected), +//! Alt-drag neighbours, Shift+Alt auto layout padding and the `measure.*` commands. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::distance::{between, label, to_guide}; +use crate::params::id_of; +use crate::snap::Guide; +use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::NodeId; +use figcraft_geom::Rect; + +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, 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_of(&ex(s, "shape.rectangle", p)["id"]).unwrap() +} + +fn frame(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&ex(s, "frame.create", json!({"x": x, "y": y, "width": w, "height": h}))["id"]).unwrap() +} + +fn hover(s: &mut Session, x: f64, y: f64, alt: bool, shift: bool, cmd: bool) -> Vec { + let mut ev = PointerEvent::at(PointerKind::Hover, x, y, 1.0); + (ev.alt, ev.shift, ev.cmd) = (alt, shift, cmd); + s.pointer(&ev).unwrap(); + s.tool.overlay.guides.clone() +} + +/// The labelled lines as (label, x0, y0, x1, y1), rounded. +fn labelled(g: &[Guide]) -> Vec<(String, [i64; 4])> { + let mut v: Vec<(String, [i64; 4])> = + g.iter().filter(|g| !g.dashed).map(|g| (g.label.clone().unwrap_or_default(), [g.x0, g.y0, g.x1, g.y1].map(|v| v.round() as i64))).collect(); + v.sort(); + v +} + +fn labels(g: &[Guide]) -> Vec { + labelled(g).into_iter().map(|l| l.0).collect() +} + +#[test] +fn labels_round_to_two_decimals() { + assert_eq!(label(50.0), "50"); + assert_eq!(label(12.345), "12.35"); + assert_eq!(label(-3.5), "3.5"); +} + +#[test] +fn gaps_between_boxes_side_by_side_and_diagonal() { + let a = Rect::new(0.0, 0.0, 50.0, 50.0); + // Side by side: one line across the shared rows. + let g = between(a, Rect::new(100.0, 10.0, 150.0, 70.0)); + assert_eq!(labelled(&g), vec![("50".into(), [50, 30, 100, 30])]); + assert!(g.iter().all(|g| !g.dashed)); + // Diagonal: both gaps from the middle of the selection, each with a dashed extension to the box. + let g = between(a, Rect::new(100.0, 120.0, 150.0, 150.0)); + assert_eq!(labelled(&g), vec![("50".into(), [50, 25, 100, 25]), ("70".into(), [25, 50, 25, 120])]); + let dashed: Vec<[i64; 4]> = g.iter().filter(|g| g.dashed).map(|g| [g.x0, g.y0, g.x1, g.y1].map(|v| v.round() as i64)).collect(); + assert_eq!(dashed, vec![[100, 25, 100, 120], [25, 120, 100, 120]]); + assert!(g.iter().filter(|g| g.dashed).all(|g| g.label.is_none())); + // Touching boxes have no gap to draw. + assert!(between(a, Rect::new(50.0, 0.0, 80.0, 50.0)).is_empty()); +} + +#[test] +fn containment_measures_to_each_edge_both_ways() { + let inner = Rect::new(10.0, 20.0, 60.0, 50.0); + let outer = Rect::new(0.0, 0.0, 100.0, 100.0); + let want = vec![ + ("10".to_string(), [0, 35, 10, 35]), + ("20".to_string(), [35, 0, 35, 20]), + ("40".to_string(), [60, 35, 100, 35]), + ("50".to_string(), [35, 50, 35, 100]), + ]; + assert_eq!(labelled(&between(inner, outer)), want); + // Hovering a layer inside the selection measures from it to the selection's edges. + assert_eq!(labelled(&between(outer, inner)), want); +} + +#[test] +fn overlapping_boxes_measure_between_matching_edges() { + let g = between(Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(50.0, 30.0, 200.0, 80.0)); + assert_eq!(labels(&g), vec!["100", "20", "30", "50"]); +} + +#[test] +fn guides_measure_to_the_near_side_or_both() { + let a = Rect::new(0.0, 0.0, 50.0, 40.0); + assert_eq!(labelled(&to_guide(a, true, 300.0)), vec![("250".into(), [50, 20, 300, 20])]); + assert_eq!(labelled(&to_guide(a, false, -10.0)), vec![("10".into(), [25, -10, 25, 0])]); + assert_eq!(labels(&to_guide(a, true, 20.0)), vec!["20", "30"]); + assert!(to_guide(a, true, f64::NAN).is_empty()); +} + +#[test] +fn alt_hover_measures_the_selection_to_the_hovered_layer() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0, None); + rect(&mut s, 100.0, 0.0, 50.0, 50.0, None); + ex(&mut s, "select.set", json!({"ids": [a]})); + assert!(hover(&mut s, 125.0, 25.0, false, false, false).is_empty(), "no Alt, no lines"); + assert_eq!(s.tool.overlay.measure_box, None); + assert_eq!(labelled(&hover(&mut s, 125.0, 25.0, true, false, false)), vec![("50".into(), [50, 25, 100, 25])]); + assert_eq!(s.tool.overlay.measure_box, Some([100.0, 0.0, 150.0, 50.0]), "the measured layer is outlined"); + // Releasing Alt clears them. + hover(&mut s, 125.0, 25.0, false, false, false); + assert_eq!(s.tool.overlay.measure_box, None); +} + +#[test] +fn alt_over_the_selection_or_empty_space_measures_to_the_parent() { + let mut s = Session::new(); + let f = frame(&mut s, 0.0, 0.0, 200.0, 100.0); + let r = rect(&mut s, 20.0, 30.0, 40.0, 20.0, Some(f)); + ex(&mut s, "select.set", json!({"ids": [r]})); + let want = vec![ + ("140".to_string(), [60, 40, 200, 40]), + ("20".to_string(), [0, 40, 20, 40]), + ("30".to_string(), [40, 0, 40, 30]), + ("50".to_string(), [40, 50, 40, 100]), + ]; + assert_eq!(labelled(&hover(&mut s, 30.0, 40.0, true, false, false)), want, "over the selection itself"); + assert_eq!(s.tool.overlay.measure_box, Some([0.0, 0.0, 200.0, 100.0])); + assert_eq!(labelled(&hover(&mut s, 500.0, 500.0, true, false, false)), want, "over empty canvas"); + // A top-level selection has no parent to measure to. + ex(&mut s, "select.set", json!({"ids": [f]})); + assert!(hover(&mut s, 500.0, 500.0, true, false, false).is_empty()); +} + +#[test] +fn cmd_alt_measures_to_nested_layers() { + let mut s = Session::new(); + let a = rect(&mut s, -100.0, 0.0, 50.0, 50.0, None); + let f = frame(&mut s, 0.0, 0.0, 300.0, 200.0); + let g1 = rect(&mut s, 20.0, 20.0, 40.0, 40.0, Some(f)); + let g2 = rect(&mut s, 80.0, 20.0, 40.0, 40.0, Some(f)); + ex(&mut s, "object.group", json!({"ids": [g1, g2]})); + ex(&mut s, "select.set", json!({"ids": [a]})); + // Alt alone measures to what a click would select (the group in the frame)... + hover(&mut s, 100.0, 40.0, true, false, false); + assert_eq!(s.tool.overlay.measure_box, Some([20.0, 20.0, 120.0, 60.0])); + // ...Cmd+Alt to the layer under the pointer. + let g = hover(&mut s, 100.0, 40.0, true, false, true); + assert_eq!(s.tool.overlay.measure_box, Some([80.0, 20.0, 120.0, 60.0])); + assert_eq!(labelled(&g), vec![("130".into(), [-50, 35, 80, 35])]); +} + +#[test] +fn rotated_and_multiple_selections_measure_by_their_bounds() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0, None); + let b = rect(&mut s, 0.0, 100.0, 40.0, 40.0, None); + rect(&mut s, 200.0, 0.0, 40.0, 140.0, None); + ex(&mut s, "select.set", json!({"ids": [a, b]})); + // The selection's union (0..40 × 0..140) to the tall layer: one gap across all rows. + assert_eq!(labelled(&hover(&mut s, 220.0, 70.0, true, false, false)), vec![("160".into(), [40, 70, 200, 70])]); + // A rotated layer measures by its bounding box. + let r = rect(&mut s, 400.0, 0.0, 100.0, 100.0, None); + ex(&mut s, "node.set", json!({"id": r, "props": {"rotation": 45}})); + ex(&mut s, "select.set", json!({"ids": [a, b]})); + let rb = s.doc().unwrap().world_bounds(r).unwrap(); + let g = hover(&mut s, rb.center().x, rb.center().y, true, false, false); + assert_eq!(s.tool.overlay.measure_box, Some([rb.x0, rb.y0, rb.x1, rb.y1])); + let gap = g.iter().find(|g| g.label.is_some()).unwrap(); + assert!((gap.x0 - 40.0).abs() < 1e-9 && (gap.x1 - rb.x0).abs() < 1e-9, "{gap:?}"); +} + +#[test] +fn alt_near_a_ruler_guide_measures_to_it() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0, 50.0, 40.0, None); + ex(&mut s, "guide.add", json!({"vertical": true, "offset": 300})); + ex(&mut s, "select.set", json!({"ids": [a]})); + assert_eq!(labelled(&hover(&mut s, 301.0, 500.0, true, false, false)), vec![("250".into(), [50, 20, 300, 20])]); + assert_eq!(s.tool.overlay.measure_box, None); +} + +#[test] +fn alt_drag_shows_live_distances_to_the_neighbours() { + let mut s = Session::new(); + let f = frame(&mut s, 0.0, 0.0, 400.0, 100.0); + let a = rect(&mut s, 10.0, 30.0, 50.0, 40.0, Some(f)); + rect(&mut s, 200.0, 30.0, 50.0, 40.0, Some(f)); + ex(&mut s, "select.set", json!({"ids": [a]})); + let ev = |kind, x: f64| { + let mut e = PointerEvent::at(kind, x, 50.0, 1.0); + e.alt = true; + e + }; + s.pointer(&ev(PointerKind::Down, 30.0)).unwrap(); + // Alt duplicates: the copy moves to 110..160, between the original and the other layer. + s.pointer(&ev(PointerKind::Move, 130.0)).unwrap(); + let g = s.tool.overlay.guides.clone(); + assert_eq!( + labelled(&g), + vec![ + ("30".into(), [135, 0, 135, 30]), + ("30".into(), [135, 70, 135, 100]), + ("40".into(), [160, 50, 200, 50]), + ("50".into(), [60, 50, 110, 50]) + ] + ); + s.pointer(&ev(PointerKind::Up, 130.0)).unwrap(); + assert!(s.tool.overlay.guides.is_empty()); +} + +#[test] +fn shift_alt_reads_out_auto_layout_padding_and_gaps() { + let mut s = Session::new(); + let f = frame(&mut s, 0.0, 0.0, 300.0, 100.0); + let c = rect(&mut s, 0.0, 0.0, 20.0, 20.0, Some(f)); + rect(&mut s, 30.0, 0.0, 20.0, 20.0, Some(f)); + ex(&mut s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": [12, 16, 12, 16], "gap": 8}})); + let other = rect(&mut s, 500.0, 0.0, 10.0, 10.0, None); + ex(&mut s, "select.set", json!({"ids": [other]})); + let fb = s.doc().unwrap().world_bounds(f).unwrap(); + let cb = s.doc().unwrap().world_bounds(c).unwrap(); + let g = hover(&mut s, cb.center().x, cb.center().y, true, true, false); + assert_eq!(labels(&g), vec!["12", "12", "16", "16", "8"]); + assert_eq!(s.tool.overlay.measure_box, Some([fb.x0, fb.y0, fb.x1, fb.y1])); + // Works without a selection too, and the command says the same. + ex(&mut s, "select.none", json!({})); + assert_eq!(labels(&hover(&mut s, cb.center().x, cb.center().y, true, true, false)), vec!["12", "12", "16", "16", "8"]); + let out = ex(&mut s, "measure.padding", json!({"id": c})); + assert_eq!(id_of(&out["frame"]), Some(f)); + assert_eq!(out["lines"].as_array().map(Vec::len), Some(5)); + // Not in auto layout: Shift+Alt measures as usual. + assert!(s.execute("measure.padding", &json!({"id": other})).is_err()); +} + +#[test] +fn measure_distances_command() { + let mut s = Session::new(); + let f = frame(&mut s, 0.0, 0.0, 200.0, 100.0); + let r = rect(&mut s, 20.0, 30.0, 40.0, 20.0, Some(f)); + let other = rect(&mut s, 300.0, 30.0, 10.0, 10.0, None); + let out = ex(&mut s, "measure.distances", json!({"ids": [r]})); + assert_eq!(out["lines"].as_array().map(Vec::len), Some(4), "to the parent by default"); + let out = ex(&mut s, "measure.distances", json!({"ids": [r], "to": other})); + assert_eq!(out["lines"][0]["label"], json!("240")); + assert!(s.execute("measure.distances", &json!({"ids": [f]})).is_err(), "top level: pass `to`"); + assert!(s.execute("measure.distances", &json!({"ids": [r], "to": 99999})).is_err()); +} diff --git a/crates/engine/src/scale_tool_tests.rs b/crates/engine/src/scale_tool_tests.rs new file mode 100644 index 00000000..647a7ce1 --- /dev/null +++ b/crates/engine/src/scale_tool_tests.rs @@ -0,0 +1,242 @@ +//! The Scale tool (K): on-canvas handles scale everything by one factor about the anchor point +//! (Alt: the centre), one undo step per drag, and `scale_node` scales every size-like property. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{Handle, PointerEvent, PointerKind, ToolId, anchor_name, anchor_of, scale_factor}; +use figcraft_doc::{Effect, EffectKind, NodeId, NodeKind}; +use figcraft_geom::{Point, Rect}; + +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, w: f64, h: f64) -> NodeId { + id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"]).unwrap() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().world_bounds(id).unwrap() +} + +fn assert_rect(r: Rect, want: [f64; 4]) { + assert!(near(r.x0, want[0]) && near(r.y0, want[1]) && near(r.x1, want[2]) && near(r.y1, want[3]), "{r:?} != {want:?}"); +} + +/// Drag from `a` to `b` with the current tool (`alt` held throughout). +fn drag(s: &mut Session, a: (f64, f64), b: (f64, f64), alt: bool) { + for (kind, (x, y)) in + [(PointerKind::Down, a), (PointerKind::Move, ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0)), (PointerKind::Move, b), (PointerKind::Up, b)] + { + let mut ev = PointerEvent::at(kind, x, y, 1.0); + ev.alt = alt; + s.pointer(&ev).unwrap(); + } +} + +fn undo_len(s: &Session) -> usize { + s.ds().unwrap().history.undo.len() +} + +#[test] +fn anchors_round_trip() { + for (i, name) in ["tl", "t", "tr", "l", "c", "r", "bl", "b", "br"].into_iter().enumerate() { + let a = anchor_of(name).unwrap(); + assert_eq!(a, ((i % 3) as u8, (i / 3) as u8)); + assert_eq!(anchor_name(a), name); + } + assert_eq!(anchor_of(" BR "), Some((2, 2))); + assert_eq!(anchor_of("middle"), None); + assert_eq!(anchor_name((9, 9)), "br"); +} + +#[test] +fn the_pivot_is_the_anchor_unless_it_is_on_the_dragged_side() { + let (w, h) = (100.0, 50.0); + let tl = (0, 0); + // Bottom-right corner from the top-left anchor: classic, by the axis that moved the most. + assert_eq!(scale_factor(Handle::Corner(2), Point::new(200.0, 60.0), w, h, tl, false), Some((2.0, Point::new(0.0, 0.0)))); + // Top-right corner: the anchor is on its top side, so the opposite corner stays put. + assert_eq!(scale_factor(Handle::Corner(1), Point::new(150.0, -25.0), w, h, tl, false), Some((1.5, Point::new(0.0, 50.0)))); + // Right edge: from the anchor; left edge: the anchor is on it, so from the right edge. + assert_eq!(scale_factor(Handle::Edge(1), Point::new(50.0, 999.0), w, h, tl, false), Some((0.5, Point::new(0.0, 0.0)))); + assert_eq!(scale_factor(Handle::Edge(3), Point::new(-100.0, 0.0), w, h, tl, false), Some((2.0, Point::new(100.0, 25.0)))); + // A centre anchor, or Alt, scales about the centre. + assert_eq!(scale_factor(Handle::Corner(2), Point::new(150.0, 75.0), w, h, (1, 1), false), Some((2.0, Point::new(50.0, 25.0)))); + assert_eq!(scale_factor(Handle::Edge(2), Point::new(0.0, 75.0), w, h, tl, true), Some((2.0, Point::new(50.0, 25.0)))); + // Past the pivot flips; it never collapses to nothing. + assert_eq!(scale_factor(Handle::Corner(2), Point::new(-100.0, -50.0), w, h, tl, false).map(|k| k.0), Some(-1.0)); + assert_eq!(scale_factor(Handle::Corner(2), Point::new(0.0, 0.0), w, h, tl, false).map(|k| k.0), Some(0.01)); + // Lines (no height) scale along their length. + assert_eq!(scale_factor(Handle::Corner(1), Point::new(300.0, 7.0), 100.0, 0.0, tl, false).map(|k| k.0), Some(3.0)); + for bad in [f64::NAN, f64::INFINITY] { + assert_eq!(scale_factor(Handle::Corner(2), Point::new(bad, 0.0), w, h, tl, false), None); + } + assert_eq!(scale_factor(Handle::Rotate(0), Point::new(1.0, 1.0), w, h, tl, false), None); + assert_eq!(scale_factor(Handle::Corner(2), Point::new(5.0, 5.0), 0.0, 0.0, tl, false), None); +} + +#[test] +fn dragging_a_handle_scales_strokes_effects_and_radii_in_one_undo_step() { + let mut s = Session::new(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0); + ex(&mut s, "node.set", json!({"id": r, "props": {"strokes": ["#000000"], "strokeWeight": 2, "cornerRadius": 8}})); + s.doc_mut().unwrap().get_mut(r).unwrap().effects = vec![Effect::drop_shadow()]; + ex(&mut s, "select.set", json!({"ids": [r]})); + ex(&mut s, "tool.scale", json!({})); + let before = undo_len(&s); + drag(&mut s, (100.0, 50.0), (200.0, 100.0), false); + assert_rect(bounds(&s, r), [0.0, 0.0, 200.0, 100.0]); + let n = s.doc().unwrap().node(r).unwrap().clone(); + assert!(near(n.stroke.weight, 4.0) && near(n.corners.radii[0], 16.0), "{:?} {:?}", n.stroke.weight, n.corners.radii); + let EffectKind::DropShadow(sh) = &n.effects[0].kind else { panic!() }; + assert!(near(sh.radius, 8.0) && near(sh.offset.1, 8.0)); + assert_eq!(undo_len(&s), before + 1, "one step per drag"); + assert!(s.tool.overlay.scale.is_none(), "the readout goes with the drag"); + s.undo().unwrap(); + assert_rect(bounds(&s, r), [0.0, 0.0, 100.0, 50.0]); + assert!(near(s.doc().unwrap().node(r).unwrap().stroke.weight, 2.0)); +} + +#[test] +fn the_live_readout_shows_the_factor_and_pivot() { + let mut s = Session::new(); + let r = rect(&mut s, 10.0, 10.0, 100.0, 100.0); + ex(&mut s, "select.set", json!({"ids": [r]})); + ex(&mut s, "tool.set", json!({"tool": "scale"})); + s.pointer(&PointerEvent::at(PointerKind::Down, 110.0, 110.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, 160.0, 160.0, 1.0)).unwrap(); + let hint = s.tool.overlay.scale.unwrap(); + assert!(near(hint.factor, 1.5) && near(hint.pivot[0], 10.0) && near(hint.pivot[1], 10.0), "{hint:?}"); + s.pointer(&PointerEvent::at(PointerKind::Up, 160.0, 160.0, 1.0)).unwrap(); +} + +#[test] +fn the_anchor_and_alt_choose_what_stays_put() { + let mut s = Session::new(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0); + ex(&mut s, "select.set", json!({"ids": [r]})); + ex(&mut s, "tool.scale", json!({})); + assert_eq!(ex(&mut s, "tool.scaleAnchor", json!({"anchor": "c"}))["anchor"], json!("c")); + assert_eq!(ex(&mut s, "tool.scaleAnchor", json!({}))["anchor"], json!("c"), "reads it back"); + assert!(s.execute("tool.scaleAnchor", &json!({"anchor": "nowhere"})).is_err()); + drag(&mut s, (100.0, 50.0), (150.0, 75.0), false); + assert_rect(bounds(&s, r), [-50.0, -25.0, 150.0, 75.0]); + // Alt scales from the centre whatever the anchor. + ex(&mut s, "tool.scaleAnchor", json!({"anchor": "tl"})); + drag(&mut s, (150.0, 75.0), (125.0, 62.5), true); + assert_rect(bounds(&s, r), [-25.0, -12.5, 125.0, 62.5]); +} + +#[test] +fn frames_scale_their_children_whatever_their_constraints() { + let mut s = Session::new(); + let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100}))["id"]).unwrap(); + let c = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 150, "y": 60, "width": 40, "height": 30, "parent": f}))["id"]).unwrap(); + ex(&mut s, "node.set", json!({"id": c, "props": {"constraints": {"horizontal": "right", "vertical": "bottom"}}})); + let t = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": f}))["id"]).unwrap(); + ex(&mut s, "node.set", json!({"id": t, "props": {"paragraphIndent": 4}})); + let size = |s: &Session| match &s.doc().unwrap().node(t).unwrap().kind { + NodeKind::Text(td) => (td.style.font_size, td.paragraph_indent), + _ => (0.0, 0.0), + }; + let (fs, _) = size(&s); + ex(&mut s, "select.set", json!({"ids": [f]})); + ex(&mut s, "tool.scale", json!({})); + drag(&mut s, (200.0, 100.0), (300.0, 150.0), false); + assert_rect(bounds(&s, f), [0.0, 0.0, 300.0, 150.0]); + assert_rect(bounds(&s, c), [225.0, 90.0, 285.0, 135.0]); + let (fs2, indent) = size(&s); + assert!(near(fs2, fs * 1.5) && near(indent, 6.0), "{fs2} {indent}"); +} + +#[test] +fn several_layers_scale_together_about_the_pivot() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0); + let b = rect(&mut s, 100.0, 50.0, 50.0, 50.0); + ex(&mut s, "node.set", json!({"ids": [a, b], "props": {"strokes": ["#000000"], "strokeWeight": 1}})); + ex(&mut s, "select.set", json!({"ids": [a, b]})); + ex(&mut s, "tool.scale", json!({})); + let before = undo_len(&s); + drag(&mut s, (150.0, 100.0), (300.0, 200.0), false); + assert_rect(bounds(&s, a), [0.0, 0.0, 100.0, 100.0]); + assert_rect(bounds(&s, b), [200.0, 100.0, 300.0, 200.0]); + assert!(near(s.doc().unwrap().node(b).unwrap().stroke.weight, 2.0)); + assert_eq!(undo_len(&s), before + 1); +} + +#[test] +fn rotated_layers_and_lines_scale_along_their_own_axes() { + let mut s = Session::new(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0); + ex(&mut s, "node.set", json!({"id": r, "props": {"rotation": 30}})); + ex(&mut s, "select.set", json!({"ids": [r]})); + ex(&mut s, "tool.scale", json!({})); + let w = s.doc().unwrap().world_transform(r); + let (origin, from, to) = (w * Point::ZERO, w * Point::new(100.0, 50.0), w * Point::new(200.0, 100.0)); + drag(&mut s, (from.x, from.y), (to.x, to.y), false); + let doc = s.doc().unwrap(); + let n = doc.node(r).unwrap(); + assert!(near(n.width, 200.0) && near(n.height, 100.0)); + let o = doc.world_transform(r) * Point::ZERO; + assert!(near(o.x, origin.x) && near(o.y, origin.y), "the anchor corner stays: {o:?} {origin:?}"); + let c = doc.world_transform(r).as_coeffs(); + assert!(near(c[1].atan2(c[0]).to_degrees().abs(), 30.0), "{c:?}"); + // A line scales its length and stroke. + let l = id_of(&ex(&mut s, "shape.line", json!({"x": 0, "y": 300, "width": 100}))["id"]).unwrap(); + let wt = s.doc().unwrap().node(l).unwrap().stroke.weight; + ex(&mut s, "select.set", json!({"ids": [l]})); + drag(&mut s, (100.0, 300.0), (200.0, 300.0), false); + let n = s.doc().unwrap().node(l).unwrap(); + assert!(near(n.width, 200.0) && near(n.stroke.weight, wt * 2.0), "{} {}", n.width, n.stroke.weight); +} + +#[test] +fn dragging_past_the_pivot_turns_the_layer_over() { + let mut s = Session::new(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0); + ex(&mut s, "select.set", json!({"ids": [r]})); + ex(&mut s, "tool.scale", json!({})); + drag(&mut s, (100.0, 50.0), (-100.0, -50.0), false); + assert_rect(bounds(&s, r), [-100.0, -50.0, 0.0, 0.0]); + let n = s.doc().unwrap().node(r).unwrap(); + assert!(near(n.width, 100.0) && near(n.height, 50.0)); +} + +#[test] +fn scale_node_scales_every_size_like_property() { + let mut s = Session::new(); + let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100}))["id"]).unwrap(); + ex( + &mut s, + "node.set", + json!({"id": f, "props": {"minWidth": 50, "maxWidth": 400, "grids": [{"kind": "columns", "count": 4, "gutter": 20, "offset": 10, "size": 30, "align": "min"}]}}), + ); + ex(&mut s, "guide.add", json!({"frame": f, "vertical": true, "offset": 40})); + ex(&mut s, "transform.scale", json!({"ids": [f], "factor": 2})); + let n = s.doc().unwrap().node(f).unwrap(); + assert_eq!((n.child_layout.min_w, n.child_layout.max_w), (Some(100.0), Some(800.0))); + let g = &n.grids[0]; + assert_eq!((g.gutter, g.offset, g.size, g.count), (40.0, 20.0, 60.0, 4)); + assert_eq!(n.guides[0].offset, 80.0); + assert_eq!(n.width, 400.0); +} + +#[test] +fn the_scale_tool_only_acts_on_handles() { + // Clicking a layer with K selects and moves it like the Move tool. + let mut s = Session::new(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0); + s.set_tool(ToolId::Scale); + drag(&mut s, (50.0, 25.0), (60.0, 35.0), false); + assert_eq!(s.selection(), vec![r]); + assert_rect(bounds(&s, r), [10.0, 10.0, 110.0, 60.0]); + assert!(near(s.doc().unwrap().node(r).unwrap().width, 100.0)); +} diff --git a/crates/engine/src/selection_depth_tests.rs b/crates/engine/src/selection_depth_tests.rs new file mode 100644 index 00000000..5897ac6f --- /dev/null +++ b/crates/engine/src/selection_depth_tests.rs @@ -0,0 +1,125 @@ +//! Selection behaviours: Cmd/Ctrl-click and Cmd/Ctrl-marquee into nested layers, Shift-marquee +//! toggling, Enter / Shift+Enter / Tab / Shift+Tab, and the layers under a point (Select layer). + +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) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn rect_in(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_of(&ex(s, "shape.rectangle", p)["id"]).unwrap() +} + +/// A frame (0,0 300×200) holding a group of two rectangles and a loose one; and one rectangle +/// on the page at (400, 0). +fn scene(s: &mut Session) -> (NodeId, NodeId, [NodeId; 3], NodeId) { + let f = id_of(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200}))["id"]).unwrap(); + let a = rect_in(s, 20.0, 20.0, Some(f)); + let b = rect_in(s, 80.0, 20.0, Some(f)); + let g = id_of(&ex(s, "object.group", json!({"ids": [a, b]}))["id"]).unwrap(); + let c = rect_in(s, 20.0, 120.0, Some(f)); + let out = rect_in(s, 400.0, 0.0, None); + (f, g, [a, b, c], out) +} + +fn ev(kind: PointerKind, x: f64, y: f64, shift: bool, cmd: bool) -> PointerEvent { + let mut e = PointerEvent::at(kind, x, y, 1.0); + (e.shift, e.cmd) = (shift, cmd); + e +} + +fn marquee(s: &mut Session, a: (f64, f64), b: (f64, f64), shift: bool, cmd: bool) -> Vec { + s.pointer(&ev(PointerKind::Down, a.0, a.1, shift, cmd)).unwrap(); + s.pointer(&ev(PointerKind::Move, b.0, b.1, shift, cmd)).unwrap(); + s.pointer(&ev(PointerKind::Up, b.0, b.1, shift, cmd)).unwrap(); + s.selection() +} + +fn sorted(mut v: Vec) -> Vec { + v.sort(); + v +} + +#[test] +fn cmd_click_selects_the_nested_layer() { + let mut s = Session::new(); + let (_, g, [a, ..], _) = scene(&mut s); + s.pointer(&ev(PointerKind::Down, 30.0, 30.0, false, false)).unwrap(); + s.pointer(&ev(PointerKind::Up, 30.0, 30.0, false, false)).unwrap(); + assert_eq!(s.selection(), vec![g], "a click selects the frame's child"); + ex(&mut s, "select.none", json!({})); + s.pointer(&ev(PointerKind::Down, 30.0, 30.0, false, true)).unwrap(); + s.pointer(&ev(PointerKind::Up, 30.0, 30.0, false, true)).unwrap(); + assert_eq!(s.selection(), vec![a], "Cmd/Ctrl-click goes straight to the layer"); +} + +#[test] +fn cmd_marquee_selects_nested_layers() { + let mut s = Session::new(); + let (f, _, [a, b, c], out) = scene(&mut s); + // From empty canvas, a plain marquee across the frame only touches top-level layers (and the + // frame isn't fully covered). + assert_eq!(marquee(&mut s, (-10.0, 10.0), (500.0, 70.0), false, false), vec![out]); + // Cmd/Ctrl: the innermost layers it touches, through the frame and the group. + assert_eq!(sorted(marquee(&mut s, (-10.0, 10.0), (500.0, 70.0), false, true)), sorted(vec![a, b, out])); + assert_eq!(sorted(marquee(&mut s, (-10.0, 10.0), (100.0, 190.0), false, true)), sorted(vec![a, b, c])); + // Only the frame's own background touched: the frame itself. + assert_eq!(marquee(&mut s, (350.0, 180.0), (250.0, 150.0), false, true), vec![f]); +} + +#[test] +fn shift_marquee_toggles() { + let mut s = Session::new(); + let (_, _, _, out) = scene(&mut s); + let other = rect_in(&mut s, 400.0, 100.0, None); + ex(&mut s, "select.set", json!({"ids": [out]})); + // Touching both (starting clear of the selection's handles): the selected one comes out, the + // other goes in. + assert_eq!(marquee(&mut s, (370.0, -30.0), (450.0, 150.0), true, false), vec![other]); + assert_eq!(sorted(marquee(&mut s, (370.0, -30.0), (450.0, 20.0), true, false)), sorted(vec![out, other])); +} + +#[test] +fn keyboard_selection_moves_through_the_tree() { + let mut s = Session::new(); + let (f, g, [a, b, c], _) = scene(&mut s); + ex(&mut s, "select.set", json!({"ids": [f]})); + ex(&mut s, "select.children", json!({})); + assert_eq!(sorted(s.selection()), sorted(vec![g, c]), "Enter"); + ex(&mut s, "select.set", json!({"ids": [a]})); + ex(&mut s, "select.parent", json!({})); + assert_eq!(s.selection(), vec![g], "Shift+Enter"); + ex(&mut s, "select.set", json!({"ids": [b]})); + // Tab goes down the layers list (back in z order), wrapping; Shift+Tab goes up. + ex(&mut s, "select.next", json!({})); + assert_eq!(s.selection(), vec![a], "Tab"); + ex(&mut s, "select.previous", json!({})); + assert_eq!(s.selection(), vec![b], "Shift+Tab"); + ex(&mut s, "select.previous", json!({})); + assert_eq!(s.selection(), vec![a], "wraps"); +} + +#[test] +fn layers_at_a_point_list_the_stack_front_most_first() { + let mut s = Session::new(); + let (f, g, [a, ..], _) = scene(&mut s); + let over = rect_in(&mut s, 10.0, 10.0, Some(f)); + let out = ex(&mut s, "select.layersAt", json!({"x": 30, "y": 30})); + let got: Vec<(NodeId, u64)> = + out["layers"].as_array().unwrap().iter().map(|l| (id_of(&l["id"]).unwrap(), l["depth"].as_u64().unwrap())).collect(); + assert_eq!(got, vec![(f, 0), (over, 1), (g, 1), (a, 2)]); + assert_eq!(out["layers"][0]["type"], json!("FRAME")); + // Nothing under empty canvas; junk is refused. + assert_eq!(ex(&mut s, "select.layersAt", json!({"x": 2000, "y": 2000}))["layers"], json!([])); + assert!(s.execute("select.layersAt", &json!({"x": "left"})).is_err()); +} diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 353081b1..c19a57a2 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -8,7 +8,7 @@ use figcraft_geom::{Point, Rect, Vec2}; use serde::Serialize; /// A guide line to draw: from (x0, y0) to (x1, y1) in page coordinates, with an optional label -/// (a distance) at its middle. +/// (a distance) at its middle; `dashed` marks a measurement's extension line. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct Guide { pub x0: f64, @@ -16,6 +16,15 @@ pub struct Guide { pub x1: f64, pub y1: f64, pub label: Option, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub dashed: bool, +} + +impl Guide { + /// A plain line from `a` to `b` (a marker when they are the same point). + pub fn new(a: Point, b: Point) -> Self { + Self { x0: a.x, y0: a.y, x1: b.x, y1: b.y, label: None, dashed: false } + } } /// A line to snap: its position on one axis and the span it covers on the other (x, y0, y1 for a @@ -138,7 +147,7 @@ impl Targets { let (dx, dy, mut guides) = self.snap_spans(&xs, &ys, threshold); let d = Vec2::new(dx.unwrap_or(0.0), dy.unwrap_or(0.0)); for p in pts.iter().map(|p| *p + d) { - let marker = Guide { x0: p.x, y0: p.y, x1: p.x, y1: p.y, label: None }; + let marker = Guide::new(p, p); if self.points.iter().any(|t| (*t - p).hypot() < 1e-6) && !guides.contains(&marker) { guides.push(marker); } @@ -185,12 +194,12 @@ impl Targets { let mut out = Vec::new(); for x in xs { if let Some((y0, y1)) = span(&self.xs, *x) { - out.push(Guide { x0: x.0, y0, x1: x.0, y1, label: None }); + out.push(Guide::new(Point::new(x.0, y0), Point::new(x.0, y1))); } } for y in ys { if let Some((x0, x1)) = span(&self.ys, *y) { - out.push(Guide { x0, y0: y.0, x1, y1: y.0, label: None }); + out.push(Guide::new(Point::new(x0, y.0), Point::new(x1, y.0))); } } out @@ -202,37 +211,3 @@ fn rect_spans(r: Rect) -> ([Span; 3], [Span; 3]) { let c = r.center(); ([(r.x0, r.y0, r.y1), (c.x, r.y0, r.y1), (r.x1, r.y0, r.y1)], [(r.y0, r.x0, r.x1), (c.y, r.x0, r.x1), (r.y1, r.x0, r.x1)]) } - -/// Distance lines from `r` to the nearest box on each side (Alt-hover measuring), or to `other`. -pub fn measure(r: Rect, other: Rect) -> Vec { - let mut out = Vec::new(); - let fmt = |d: f64| { - let v = (d * 100.0).round() / 100.0; - if v.fract() == 0.0 { format!("{}", v as i64) } else { format!("{v}") } - }; - if other.contains(r.origin()) && other.contains(Point::new(r.x1, r.y1)) { - // Inside: distances to each edge of the container. - let cy = r.center().y; - let cx = r.center().x; - out.push(Guide { x0: other.x0, y0: cy, x1: r.x0, y1: cy, label: Some(fmt(r.x0 - other.x0)) }); - out.push(Guide { x0: r.x1, y0: cy, x1: other.x1, y1: cy, label: Some(fmt(other.x1 - r.x1)) }); - out.push(Guide { x0: cx, y0: other.y0, x1: cx, y1: r.y0, label: Some(fmt(r.y0 - other.y0)) }); - out.push(Guide { x0: cx, y0: r.y1, x1: cx, y1: other.y1, label: Some(fmt(other.y1 - r.y1)) }); - return out; - } - let cy = (r.y0.max(other.y0) + r.y1.min(other.y1)) / 2.0; - let cx = (r.x0.max(other.x0) + r.x1.min(other.x1)) / 2.0; - let cy = if r.y0.max(other.y0) <= r.y1.min(other.y1) { cy } else { r.center().y }; - let cx = if r.x0.max(other.x0) <= r.x1.min(other.x1) { cx } else { r.center().x }; - if other.x0 >= r.x1 { - out.push(Guide { x0: r.x1, y0: cy, x1: other.x0, y1: cy, label: Some(fmt(other.x0 - r.x1)) }); - } else if other.x1 <= r.x0 { - out.push(Guide { x0: other.x1, y0: cy, x1: r.x0, y1: cy, label: Some(fmt(r.x0 - other.x1)) }); - } - if other.y0 >= r.y1 { - out.push(Guide { x0: cx, y0: r.y1, x1: cx, y1: other.y0, label: Some(fmt(other.y0 - r.y1)) }); - } else if other.y1 <= r.y0 { - out.push(Guide { x0: cx, y0: other.y1, x1: cx, y1: r.y0, label: Some(fmt(r.y0 - other.y1)) }); - } - out -} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index f7b2372c..6b7655ce 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -238,6 +238,17 @@ pub struct Overlay { /// [`crate::shapebuilder::current`]) and whether releasing removes them. pub builder_regions: Vec, pub builder_remove: bool, + /// Measuring (Alt): the box of the layer or frame measured to (x0, y0, x1, y1), outlined. + pub measure_box: Option<[f64; 4]>, + /// Scale tool drag: the factor so far and the point that stays put. + pub scale: Option, +} + +/// The Scale tool's live readout while dragging a handle. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub struct ScaleHint { + pub factor: f64, + pub pivot: [f64; 2], } #[derive(Clone, Debug)] @@ -257,7 +268,7 @@ pub(crate) enum Gesture { None, Marquee { start: Point, - #[allow(dead_code)] + /// Shift: toggle what it touches in the selection it started from (`base`). additive: bool, base: Vec, within: Option, @@ -340,8 +351,24 @@ pub struct ToolState { pub redlines: bool, /// Pen snap targets, kept while hovering (see `vectoredit::point_snap`). pub(crate) pen_targets: Option, + /// The Scale tool's anchor: column and row (0 start, 1 middle, 2 end) of the point of the + /// selection box that stays put (`tool.scaleAnchor`). + pub scale_anchor: (u8, u8), } +/// An anchor name (`tl`, `t`, `tr`, `l`, `c`, `r`, `bl`, `b`, `br`) as column and row (0 start, +/// 1 middle, 2 end). +pub fn anchor_of(name: &str) -> Option<(u8, u8)> { + ANCHORS.iter().position(|a| a.eq_ignore_ascii_case(name.trim())).map(|i| ((i % 3) as u8, (i / 3) as u8)) +} + +/// The name of anchor `(column, row)`. +pub fn anchor_name(a: (u8, u8)) -> &'static str { + ANCHORS.get(usize::from(a.1.min(2)) * 3 + usize::from(a.0.min(2))).copied().unwrap_or("tl") +} + +const ANCHORS: [&str; 9] = ["tl", "t", "tr", "l", "c", "r", "bl", "b", "br"]; + impl ToolState { pub fn busy(&self) -> bool { !matches!(self.gesture, Gesture::None) @@ -574,6 +601,86 @@ pub(crate) fn resized_box(handle: Handle, q: Point, w0: f64, h0: f64, keep: bool (x0, y0, x1, y1) } +/// A handle's point in its box's axes (`w0`×`h0`): a corner or an edge's middle. +fn handle_point(h: Handle, w0: f64, h0: f64) -> Point { + match h { + Handle::Corner(0) | Handle::Rotate(0) => Point::new(0.0, 0.0), + Handle::Corner(1) | Handle::Rotate(1) => Point::new(w0, 0.0), + Handle::Corner(2) | Handle::Rotate(2) => Point::new(w0, h0), + Handle::Corner(_) | Handle::Rotate(_) => Point::new(0.0, h0), + Handle::Edge(0) => Point::new(w0 / 2.0, 0.0), + Handle::Edge(1) => Point::new(w0, h0 / 2.0), + Handle::Edge(2) => Point::new(w0 / 2.0, h0), + Handle::Edge(_) => Point::new(0.0, h0 / 2.0), + } +} + +/// Anchor `(column, row)` of a `w`×`h` box, in its axes. +fn anchor_in_box(anchor: (u8, u8), w: f64, h: f64) -> Point { + Point::new(w * f64::from(anchor.0.min(2)) / 2.0, h * f64::from(anchor.1.min(2)) / 2.0) +} + +/// The Scale tool's anchor point on the selection's handle box, in page coordinates. +pub fn scale_anchor_point(doc: &Document, sel: &[NodeId], anchor: (u8, u8)) -> Option { + let (f, w, h) = selection_frame(doc, sel)?; + Some(f * anchor_in_box(anchor, w, h)) +} + +/// The handle across the box from `h`. +fn opposite(h: Handle) -> Handle { + match h { + Handle::Corner(i) => Handle::Corner((i + 2) % 4), + Handle::Edge(i) => Handle::Edge((i + 2) % 4), + Handle::Rotate(i) => Handle::Rotate((i + 2) % 4), + } +} + +/// The Scale tool's factor and pivot (in the box's axes) for dragging `handle` of a `w0`×`h0` box +/// to `q`. The pivot is the anchor point (`anchor`: column, row), or the opposite handle when the +/// anchor lies on the side being dragged, or the centre (`from_center`). Corners follow the axis +/// that moved the most; a negative factor flips. None when the box has no size to scale by. +pub(crate) fn scale_factor(handle: Handle, q: Point, w0: f64, h0: f64, anchor: (u8, u8), from_center: bool) -> Option<(f64, Point)> { + const EPS: f64 = 1e-9; + if !(q.x.is_finite() && q.y.is_finite() && w0.is_finite() && h0.is_finite()) || matches!(handle, Handle::Rotate(_)) { + return None; + } + let h = handle_point(handle, w0, h0); + let anchor_pt = anchor_in_box(anchor, w0, h0); + // The axes the handle moves along. + let (ax_x, ax_y) = match handle { + Handle::Edge(0) | Handle::Edge(2) => (false, true), + Handle::Edge(_) => (true, false), + _ => (true, true), + }; + let on_side = (ax_x && (anchor_pt.x - h.x).abs() < EPS) || (ax_y && (anchor_pt.y - h.y).abs() < EPS); + let pivot = if from_center { + Point::new(w0 / 2.0, h0 / 2.0) + } else if on_side { + handle_point(opposite(handle), w0, h0) + } else { + anchor_pt + }; + let ratio = |on: bool, to: f64, from: f64, p: f64| (on && (from - p).abs() > EPS).then(|| (to - p) / (from - p)); + let kx = ratio(ax_x, q.x, h.x, pivot.x); + let ky = ratio(ax_y, q.y, h.y, pivot.y); + let k = match (kx, ky) { + (Some(a), Some(b)) => { + if a.abs() > b.abs() { + a + } else { + b + } + } + (Some(a), None) | (None, Some(a)) => a, + (None, None) => return None, + }; + if !k.is_finite() { + return None; + } + // Never collapse to nothing (or blow up past a usable size); 0 counts as positive. + Some((k.signum() * k.abs().clamp(0.01, 1000.0), pivot)) +} + impl Session { pub fn set_tool(&mut self, t: ToolId) { if self.tool.tool == ToolId::Pen && t != ToolId::Pen { @@ -651,31 +758,33 @@ impl Session { }; let mut hover = None; let mut guides = Vec::new(); + let mut measure_box = None; if matches!(tool, ToolId::Move | ToolId::Scale) { if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom) { cursor = resize_cursor(&doc, &sel, h); - } else if let Some(leaf) = doc.hit_test(page, ev.p(), ev.tol() * 2.0) { - let t = select_target(&doc, leaf, &sel, ev.cmd); - hover = Some(t); - if (ev.alt || self.tool.redlines) && !sel.is_empty() && !sel.contains(&t) { - // Alt: measure from the selection to the hovered layer. - if let (Some(a), Some(b)) = (doc.bounds_of(&sel), doc.world_bounds(t)) { - guides = crate::snap::measure(a, b); + } else { + hover = doc.hit_test(page, ev.p(), ev.tol() * 2.0).map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)); + if ev.alt || self.tool.redlines { + use crate::distance::{Hover, guide_at, hover_lines, padding_frame, parent_of}; + // Shift+Alt over an auto layout frame: its padding and gaps. Otherwise from the + // selection to a ruler guide, the hovered layer or (over empty canvas) the parent. + let around: Vec = sel.iter().copied().chain(hover).collect(); + let what = hover.filter(|_| ev.alt && ev.shift).and_then(|t| padding_frame(&doc, t)).map(Hover::Padding).or_else(|| { + if sel.is_empty() { + return None; + } + let guide = guide_at(&doc, page, &around, ev.p(), ev.tol() * 4.0).map(|(vertical, at)| Hover::Guide { vertical, at }); + guide.or_else(|| hover.or_else(|| parent_of(&doc, page, &sel)).map(Hover::Layer)) + }); + if let Some(what) = what { + let (g, b) = hover_lines(&doc, page, &sel, what); + guides = g; + measure_box = b.map(|b| [b.x0, b.y0, b.x1, b.y1]); } } - } else if (ev.alt || self.tool.redlines) && !sel.is_empty() { - // Alt over empty canvas inside a frame: distances to the parent. - if let Some(a) = doc.bounds_of(&sel) - && let Some(p) = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).filter(|p| *p != page) - && let Some(b) = doc.world_bounds(p) - { - guides = crate::snap::measure(a, b); - } - } - if ev.alt && hover.is_some() && sel.is_empty() { - cursor = Cursor::Default; } } + self.tool.overlay.measure_box = measure_box; self.tool.overlay.cursor = cursor; if tool == ToolId::Pen { // Where a click would land (snapped), with its guides. @@ -776,22 +885,28 @@ impl Session { let g = std::mem::take(&mut self.tool.gesture); let res = match &g { Gesture::None => return self.hover(ev), - Gesture::Marquee { start, additive: _, base, within } => { + 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()?; - let mut hits = figcraft_doc::geometry::marquee_select(&doc, page, r, *within); + // 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) + }; // A marquee over a frame from outside selects the frame only when it covers it fully. - if within.is_none() { + 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 { .. })) }); } - let mut sel = base.clone(); + // 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 !sel.contains(&h) { + if !base.contains(&h) && !sel.contains(&h) { sel.push(h); } } @@ -891,11 +1006,19 @@ impl Session { drop_target = (flow && target != page).then_some(target); Ok(()) })?; - // Snapping guides mean nothing while the layout places the layers. - self.tool.overlay.guides = if in_flow { Vec::new() } else { guides }; + let placed = if dup { self.selection() } else { ids }; + // Snapping guides mean nothing while the layout places the layers; with Alt held, + // live distances to the neighbours replace them. + self.tool.overlay.guides = if in_flow { + Vec::new() + } else if ev.alt { + let doc = self.doc()?; + doc.bounds_of(&placed).map(|b| crate::distance::neighbours(doc, page, &placed, b)).unwrap_or_default() + } else { + guides + }; // The preview already shows the layers in their slot: the line marks it there, in // the middle of the gap before them (clear of the selection box). - let placed = if dup { self.selection() } else { ids }; self.tool.overlay.insert_line = slot_axis.and_then(|(vertical, gap)| { let off = if gap.is_finite() { (gap / 2.0).clamp(3.0, 24.0) } else { 3.0 }; self.doc().ok()?.bounds_of(&placed).map(|b| crate::drop::line_before(b, vertical, off)) @@ -990,6 +1113,9 @@ impl Session { let (frame, w0, h0) = r.frame; 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 { + return self.scale_drag(ev, r, q, guides); + } if is_line { // Line end handles: move the end point freely (length + angle). let (a, b) = match r.handle { @@ -1013,9 +1139,7 @@ impl Session { return Ok(()); } // Constrain proportions (Shift, or the node's lock). - let keep = ev.shift - || r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| n.constrain_proportions)).unwrap_or(false) - || scale_tool; + let keep = ev.shift || r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| n.constrain_proportions)).unwrap_or(false); let (x0, y0, x1, y1) = resized_box(r.handle, q, w0, h0, keep, ev.alt); let (flip_x, flip_y) = (x1 < x0, y1 < y0); let (nx0, ny0) = (x0.min(x1), y0.min(y1)); @@ -1034,25 +1158,19 @@ impl Session { t *= Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, nh]); } doc.set_world_transform(*id, t)?; - if scale_tool { - let (_, _, ow, _) = nodes.first().copied().unwrap_or((NodeId(0), Affine::IDENTITY, 1.0, 1.0)); - let k = if ow > 1e-9 { nw / ow } else { 1.0 }; - crate::cmd::arrange::scale_node(doc, *id, k)?; - } else { - crate::cmd::props::resize_node(doc, *id, nw.max(0.01), nh.max(0.01))?; - let n = doc.get_mut(*id)?; - if matches!(handle, Handle::Corner(_) | Handle::Edge(1) | Handle::Edge(3)) { - n.child_layout.sizing_h = Sizing::Fixed; - } - if matches!(handle, Handle::Corner(_) | Handle::Edge(0) | Handle::Edge(2)) { - n.child_layout.sizing_v = Sizing::Fixed; - } - if let NodeKind::Text(t) = &mut n.kind { - t.auto_resize = match handle { - Handle::Edge(1) | Handle::Edge(3) => TextAutoResize::Height, - _ => TextAutoResize::None, - }; - } + crate::cmd::props::resize_node(doc, *id, nw.max(0.01), nh.max(0.01))?; + let n = doc.get_mut(*id)?; + if matches!(handle, Handle::Corner(_) | Handle::Edge(1) | Handle::Edge(3)) { + n.child_layout.sizing_h = Sizing::Fixed; + } + if matches!(handle, Handle::Corner(_) | Handle::Edge(0) | Handle::Edge(2)) { + n.child_layout.sizing_v = Sizing::Fixed; + } + if let NodeKind::Text(t) = &mut n.kind { + t.auto_resize = match handle { + Handle::Edge(1) | Handle::Edge(3) => TextAutoResize::Height, + _ => TextAutoResize::None, + }; } return Ok(()); } @@ -1064,11 +1182,7 @@ impl Session { 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]))?; - if scale_tool { - crate::cmd::arrange::scale_node(doc, *id, sx.min(sy))?; - } else { - crate::cmd::props::resize_node(doc, *id, w * sx, h * sy)?; - } + crate::cmd::props::resize_node(doc, *id, w * sx, h * sy)?; } Ok(()) })?; @@ -1076,6 +1190,31 @@ impl Session { Ok(()) } + /// Scale tool handle drag (`q`: the snapped pointer in the handle box's axes): everything + /// scales by one factor about the anchor point (Alt: the centre), strokes, effects, radii and + /// text included, children by the same factor whatever their constraints. + fn scale_drag(&mut self, ev: &PointerEvent, r: &ResizeStart, q: Point, guides: Vec) -> Result<()> { + let (frame, w0, h0) = r.frame; + let Some((k, pivot)) = scale_factor(r.handle, q, w0, h0, self.tool.scale_anchor, ev.alt) else { return Ok(()) }; + let nodes = r.nodes.clone(); + let inv = frame.inverse(); + // A layer's place in the box scales about the pivot; a negative factor turns it over. + let about = frame * Affine::translate(pivot.to_vec2() * (1.0 - k)); + let sign = k.signum(); + self.preview(|doc, _| { + for (id, world, _, _) in &nodes { + let c = (inv * *world).as_coeffs(); + doc.set_world_transform(*id, about * Affine::new([sign * c[0], sign * c[1], sign * c[2], sign * c[3], k * c[4], k * c[5]]))?; + crate::cmd::arrange::scale_node(doc, *id, k.abs())?; + } + Ok(()) + })?; + let p = frame * pivot; + self.tool.overlay.scale = Some(ScaleHint { factor: k, pivot: [p.x, p.y] }); + self.tool.overlay.guides = guides; + Ok(()) + } + fn draw_down(&mut self, ev: &PointerEvent) -> Result<()> { let ds = self.ds()?; let doc = ds.doc.clone(); @@ -1178,6 +1317,8 @@ impl Session { let g = std::mem::take(&mut self.tool.gesture); self.tool.overlay.marquee = None; self.tool.overlay.guides.clear(); + self.tool.overlay.scale = None; + self.tool.overlay.measure_box = None; self.tool.overlay.insert_line = None; self.tool.overlay.drop_cell = None; let drop_target = self.tool.overlay.drop_target.take(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1b4bffa1..8410dd7e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -337,18 +337,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { return; } if resp.secondary_clicked() { - // Right-click: select what's under the pointer, then the context menu. - let ev = pointer_event(app, PointerKind::Hover, pos, &mods); - if let Ok(ds) = app.session.ds() { - let (doc, page, sel) = (ds.doc.clone(), ds.page, ds.selection.clone()); - if let Some(leaf) = doc.hit_test(page, Point::new(ev.x, ev.y), 2.0 / ev.zoom) { - let target = figcraft_doc::geometry::select_target(&doc, leaf, &sel, mods.command); - if !sel.contains(&target) { - app.session.set_selection(vec![target]); - } - } - } - app.ui.context_menu = Some((pos, app.session.selection())); + open_context_menu(app, pos, mods.command); return; } if resp.double_clicked() { @@ -393,6 +382,22 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { } } +/// Right-click at screen `pos`: select what's under the pointer (`deep`: the nested layer), then +/// open the context menu there. +pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) { + let p = app.to_page(pos); + if let Ok(ds) = app.session.ds() { + let (doc, page, sel, zoom) = (ds.doc.clone(), ds.page, ds.selection.clone(), ds.view.zoom.max(1e-6)); + if let Some(leaf) = doc.hit_test(page, p, 2.0 / zoom) { + let target = figcraft_doc::geometry::select_target(&doc, leaf, &sel, deep); + if !sel.contains(&target) { + app.session.set_selection(vec![target]); + } + } + } + app.ui.context_menu = Some((pos, app.session.selection())); +} + #[cfg(not(target_arch = "wasm32"))] fn dropped_bytes(f: &std::sync::Arc) -> Option> { f.bytes().ok() @@ -463,6 +468,14 @@ fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Document, sel: &[Nod } } +/// The outline colour for a layer: purple for components, sets and instances and every layer in +/// them, blue otherwise. +fn outline_colour(doc: &Document, id: NodeId) -> Color32 { + use figcraft_engine::components::{owning_component, owning_instance}; + let set = matches!(doc.node(id).map(|n| &n.kind), Some(NodeKind::ComponentSet { .. })); + if set || owning_component(doc, id).is_some() || owning_instance(doc, id).is_some() { COMPONENT } else { ACCENT } +} + /// The outline path of shapes (ellipses, vectors) for hover/selection outlines. fn outline_shape(app: &FigApp, doc: &Document, id: NodeId) -> Option>> { let n = doc.node(id)?; @@ -597,18 +610,14 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { } // Prototype connections. crate::proto_canvas::draw(app, painter); - // Hover outline. + // Hover outline (measuring outlines the measured box in red instead). if let Some(h) = ds.hover && !sel.contains(&h) - && app.session.tool.tool == ToolId::Move + && matches!(app.session.tool.tool, ToolId::Move | ToolId::Scale) && app.session.prefs.highlight_on_hover + && app.session.tool.overlay.measure_box.is_none() { - let col = - if matches!(doc.node(h).map(|n| &n.kind), Some(NodeKind::Component { .. } | NodeKind::Instance { .. } | NodeKind::ComponentSet { .. })) { - COMPONENT - } else { - ACCENT - }; + let col = outline_colour(&doc, h); match outline_shape(app, &doc, h) { Some(lines) => { for l in lines { @@ -640,6 +649,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { painter.add(Shape::dashed_line(&pts, stroke(ACCENT, 1.5), 4.0, 3.0)); } // Vector edit mode, with the transform box around several selected points. + // The selection's size label, which measurement labels keep clear of. + let mut size_label = None; if let Some(ve) = ds.vector_edit.clone() && let Some(o) = figcraft_engine::vectoredit::overlay(&doc, &ve) { @@ -650,16 +661,13 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { width_overlay(app, painter, &doc, &ve, zoom); } else if let Some(c) = app.session.vector_edit_box() { let label = format!("{} × {}", fmt((c[1] - c[0]).hypot()), fmt((c[3] - c[0]).hypot())); - transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), ACCENT, true, 4, label); + size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), ACCENT, true, 4, label); } } // Selection. let editing = ds.text_edit.clone(); if !sel.is_empty() && editing.is_none() && ds.vector_edit.is_none() { - let any_component = sel.iter().any(|i| { - matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Component { .. } | NodeKind::Instance { .. } | NodeKind::ComponentSet { .. })) - }); - let col = if any_component { COMPONENT } else { ACCENT }; + let col = if sel.iter().any(|i| outline_colour(&doc, *i) == COMPONENT) { COMPONENT } else { ACCENT }; for id in &sel { match outline_shape(app, &doc, *id) { Some(lines) => { @@ -680,8 +688,24 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { && let Some((_, w, h)) = selection_frame(&doc, &sel) { let is_line = sel.len() == 1 && matches!(sel.first().and_then(|i| doc.node(*i)).map(|n| &n.kind), Some(NodeKind::Line)); - let label = if is_line { fmt(w) } else { format!("{} × {}", fmt(w), fmt(h)) }; - 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); + let mut label = if is_line { fmt(w) } else { format!("{} × {}", fmt(w), fmt(h)) }; + let scale = app.session.tool.overlay.scale; + 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); + if app.session.tool.tool == ToolId::Scale { + // The point that stays put: the dragged pivot, else the anchor. + let pivot = scale + .map(|h| Point::new(h.pivot[0], h.pivot[1])) + .or_else(|| figcraft_engine::tools::scale_anchor_point(&doc, &sel, app.session.tool.scale_anchor)); + if let Some(p) = pivot { + let c = app.to_screen(p.x, p.y); + painter.circle_filled(c, 4.0, Color32::WHITE); + painter.circle_stroke(c, 4.0, stroke(col, 1.5)); + painter.circle_filled(c, 1.5, col); + } + } } } if app.session.tool.tool == ToolId::ShapeBuilder @@ -740,24 +764,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { painter.line_segment([p, p + vec2(0.0, 14.0 * zoom as f32)], stroke(t.text, 1.0)); } } - // Smart guides and measurements; a zero-length guide marks a point snapped onto. - for g in &ov.guides { - let (a, b) = (app.to_screen(g.x0, g.y0), app.to_screen(g.x1, g.y1)); - if g.x0 == g.x1 && g.y0 == g.y1 { - for d in [vec2(4.0, 4.0), vec2(4.0, -4.0)] { - painter.line_segment([a - d, a + d], stroke(t.guide, 1.5)); - } - continue; - } - painter.line_segment([a, b], stroke(t.guide, 1.0)); - if let Some(l) = &g.label { - let galley = painter.layout_no_wrap(l.clone(), semibold(10.0), Color32::WHITE); - let mid = pos2((a.x + b.x) / 2.0, (a.y + b.y) / 2.0); - let r = Rect::from_center_size(mid, galley.size() + vec2(8.0, 4.0)); - painter.rect_filled(r, CornerRadius::same(3), t.guide); - painter.galley(r.min + vec2(4.0, 2.0), galley, Color32::WHITE); - } - } + // 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 { let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1)); @@ -803,8 +811,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { } /// A transform box (screen corners tl, tr, br, bl): its outline when `outline`, the first `handles` -/// corner handles and, when dimensions are shown, the size `label` under it. -fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color32, outline: bool, handles: usize, label: String) { +/// corner handles and, when dimensions are shown, the size `label` under it (its box returned). +fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color32, outline: bool, handles: usize, label: String) -> Option { if outline { quad(painter, q, stroke(col, 1.0)); } @@ -820,7 +828,9 @@ fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color let r = Rect::from_center_size(pos2(cx, bottom + 8.0 + galley.size().y / 2.0 + 3.0), galley.size() + vec2(8.0, 4.0)); painter.rect_filled(r, CornerRadius::same(3), col); painter.galley(r.min + vec2(4.0, 2.0), galley, Color32::WHITE); + return Some(r); } + None } /// Variable width tool: each width point on the path with a bar across the stroke to its two diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e68a97f0..c8f60b0a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -274,6 +274,19 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if let Some(h) = p.get("mainMenu").and_then(Value::as_bool) { app.ui.main_menu = h; } + // A right-click on the canvas at screen [sx, sy] (null closes the menu); + // `contextSelectLayer` opens its Select layer submenu. + match p.get("contextMenu") { + Some(Value::Array(at)) => { + let (x, y) = (at.first().and_then(Value::as_f64).unwrap_or(0.0), at.get(1).and_then(Value::as_f64).unwrap_or(0.0)); + crate::canvas::open_context_menu(app, egui::pos2(x as f32, y as f32), b(p, "cmd")); + } + Some(Value::Null) => app.ui.context_menu = None, + _ => {} + } + if let Some(v) = p.get("contextSelectLayer").and_then(Value::as_bool) { + crate::menus::open_select_layer(ctx, v); + } if let Some(h) = p.get("iconGallery").and_then(Value::as_bool) { app.ui.icon_gallery = h; } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7b3f48de..bcf4faf1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -23,6 +23,7 @@ pub mod home; pub mod icons; pub mod layers; pub mod layout_panel; +pub mod measure_overlay; pub mod menus; pub mod present; pub mod proto_canvas; diff --git a/crates/ui-egui/src/measure_overlay.rs b/crates/ui-egui/src/measure_overlay.rs new file mode 100644 index 00000000..053ce861 --- /dev/null +++ b/crates/ui-egui/src/measure_overlay.rs @@ -0,0 +1,135 @@ +//! Smart guides and measurements on the canvas: red lines (dashed extension lines), a marker on a +//! point snapped onto, the outline of the layer measured to, and distance labels placed so they +//! never overlap one another and stay the same size at every zoom. + +use egui::{Color32, CornerRadius, Pos2, Rect, Shape, Stroke, StrokeKind, Vec2, pos2, vec2}; + +use crate::FigApp; +use crate::theme::{Tokens, semibold}; + +/// Space between a label and the line it labels (or another label), in screen pixels. +const GAP: f32 = 4.0; +/// Padding inside a label's pill. +const PAD: Vec2 = vec2(4.0, 2.0); + +/// Draw the tool overlay's guides, measurement labels and measured box; labels keep clear of +/// `avoid` (the selection's size label). +pub fn draw(app: &FigApp, painter: &egui::Painter, t: &Tokens, avoid: Option) { + let ov = &app.session.tool.overlay; + let line = Stroke::new(1.0, t.guide); + if let Some([x0, y0, x1, y1]) = ov.measure_box { + painter.rect_stroke(Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1)), CornerRadius::ZERO, line, StrokeKind::Middle); + } + let mut labels: Vec<(Pos2, Pos2, String)> = Vec::new(); + for g in &ov.guides { + let (a, b) = (app.to_screen(g.x0, g.y0), app.to_screen(g.x1, g.y1)); + if g.x0 == g.x1 && g.y0 == g.y1 { + for d in [vec2(4.0, 4.0), vec2(4.0, -4.0)] { + painter.line_segment([a - d, a + d], Stroke::new(1.5, t.guide)); + } + } else if g.dashed { + painter.extend(Shape::dashed_line(&[a, b], line, 3.0, 2.0)); + } else { + painter.line_segment([a, b], line); + } + if let Some(l) = &g.label { + labels.push((a, b, l.clone())); + } + } + let galleys: Vec<_> = labels.iter().map(|(_, _, l)| painter.layout_no_wrap(l.clone(), semibold(10.0), Color32::WHITE)).collect(); + let lines: Vec<(Pos2, Pos2, Vec2)> = labels.iter().zip(&galleys).map(|((a, b, _), g)| (*a, *b, g.size() + PAD * 2.0)).collect(); + for (r, g) in place_labels(&lines, avoid.as_slice()).into_iter().zip(galleys) { + painter.rect_filled(r, CornerRadius::same(3), t.guide); + painter.galley(r.min + PAD, g, Color32::WHITE); + } +} + +/// Label boxes for lines `(a, b, label size)` in screen space: centred on the line when it is long +/// enough to show around the label, else (or when that spot is taken) slid along it, then beside it +/// (below a horizontal line, right of a vertical one, then the other side, further out each time) +/// until it clears `avoid` and every label placed before it. +pub fn place_labels(lines: &[(Pos2, Pos2, Vec2)], avoid: &[Rect]) -> Vec { + let mut placed: Vec = avoid.to_vec(); + for (a, b, size) in lines { + let mid = pos2((a.x + b.x) / 2.0, (a.y + b.y) / 2.0); + let d = *b - *a; + let horizontal = d.x.abs() >= d.y.abs(); + // Along the line and out from it: the unit direction and one label's step. + let (dir, out) = if horizontal { (vec2(1.0, 0.0), vec2(0.0, 1.0)) } else { (vec2(0.0, 1.0), vec2(1.0, 0.0)) }; + let (along, across) = if horizontal { (size.x, size.y) } else { (size.y, size.x) }; + let room = (d.length() - along) / 2.0 - GAP; + let mut centres: Vec = Vec::new(); + if room >= 0.0 { + centres.push(mid); + // Slid along the line, both ways, while it still lies on the line. + let mut n = 1.0; + while n * (along + GAP) <= room && centres.len() < 32 { + centres.extend([mid + dir * n * (along + GAP), mid - dir * n * (along + GAP)]); + n += 1.0; + } + } + for n in 0..32 { + let off = across / 2.0 + GAP + n as f32 * (across + GAP); + centres.extend([mid + out * off, mid - out * off]); + } + let free = |r: &Rect| !placed.iter().any(|p| p.expand(GAP / 2.0).intersects(*r)); + // Past every candidate (dozens of labels on one spot): stack below everything placed. + let r = centres.into_iter().map(|c| Rect::from_center_size(c, *size)).find(free).unwrap_or_else(|| { + let bottom = placed.iter().map(|p| p.max.y).fold(mid.y, f32::max); + Rect::from_min_size(pos2(mid.x - size.x / 2.0, bottom + GAP), *size) + }); + placed.push(r); + } + placed.split_off(avoid.len()) +} + +#[cfg(test)] +mod tests { + use super::place_labels; + use egui::{Pos2, Rect, pos2, vec2}; + + const SIZE: egui::Vec2 = vec2(24.0, 14.0); + + fn no_overlaps(r: &[Rect]) -> bool { + r.iter().enumerate().all(|(i, a)| r.iter().skip(i + 1).all(|b| !a.intersects(*b))) + } + + #[test] + fn long_lines_carry_their_label_in_the_middle() { + let r = place_labels(&[(pos2(0.0, 50.0), pos2(200.0, 50.0), SIZE), (pos2(50.0, 0.0), pos2(50.0, 300.0), SIZE)], &[]); + assert_eq!(r[0].center(), pos2(100.0, 50.0)); + assert_eq!(r[1].center(), pos2(50.0, 150.0)); + } + + #[test] + fn short_lines_put_the_label_beside_them() { + let r = place_labels(&[(pos2(0.0, 50.0), pos2(10.0, 50.0), SIZE), (pos2(50.0, 0.0), pos2(50.0, 6.0), SIZE)], &[]); + assert!(r[0].min.y > 50.0, "below a short horizontal line: {:?}", r[0]); + assert!(r[1].min.x > 50.0, "right of a short vertical line: {:?}", r[1]); + } + + #[test] + fn labels_never_overlap_even_when_crowded() { + // Zoomed far out: every line collapses onto one spot. + let crowd: Vec<(Pos2, Pos2, egui::Vec2)> = + (0..100).map(|i| (pos2(10.0, 10.0), pos2(10.0 + (i % 2) as f32 * 0.5, 10.0 + (i % 3) as f32 * 0.5), SIZE)).collect(); + assert!(no_overlaps(&place_labels(&crowd, &[]))); + // Parallel lines a few pixels apart. + let close: Vec<(Pos2, Pos2, egui::Vec2)> = (0..6).map(|i| (pos2(0.0, i as f32 * 3.0), pos2(100.0, i as f32 * 3.0), SIZE)).collect(); + let r = place_labels(&close, &[]); + assert!(no_overlaps(&r)); + assert_eq!(r[0].center(), pos2(50.0, 0.0), "the first keeps its place"); + } + + #[test] + fn labels_keep_clear_of_the_size_label() { + let size = Rect::from_center_size(pos2(50.0, 100.0), vec2(60.0, 16.0)); + // A long line slides its label along itself, past the size label. + let r = place_labels(&[(pos2(50.0, 80.0), pos2(50.0, 200.0), SIZE)], &[size]); + assert_eq!(r.len(), 1); + assert!(!r[0].intersects(size) && r[0].center().x == 50.0, "{:?}", r[0]); + // A short one moves beside it. + let r = place_labels(&[(pos2(50.0, 90.0), pos2(50.0, 110.0), SIZE)], &[size]); + assert!(!r[0].intersects(size), "{:?}", r[0]); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 33333e7a..2068cb38 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -164,10 +164,7 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool { if m.menu == prefix { if m.label == SEP { if !last_sep { - ui.add_space(4.0); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); - ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, Color32::from_white_alpha(30))); - ui.add_space(4.0); + separator(ui); last_sep = true; } continue; @@ -210,6 +207,14 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool { false } +/// A thin line between groups of menu items. +fn separator(ui: &mut Ui) { + ui.add_space(4.0); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); + ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, Color32::from_white_alpha(30))); + ui.add_space(4.0); +} + fn menu_frame(ui: &mut Ui, t: &Tokens, add: impl FnOnce(&mut Ui)) { egui::Frame::NONE .fill(t.menu) @@ -224,6 +229,11 @@ fn menu_frame(ui: &mut Ui, t: &Tokens, add: impl FnOnce(&mut Ui)) { } fn item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, t: &Tokens) -> bool { + indented_item(ui, label, shortcut, enabled, checked, 0.0, t) +} + +/// A menu row whose label starts `indent` pixels further in (nested layers). +fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, indent: f32, t: &Tokens) -> bool { let w = ui.available_width(); let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click()); let hov = resp.hovered() && enabled; @@ -234,7 +244,8 @@ fn item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, if checked { icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)), Icon::Check, col); } - ui.painter().text(pos2(r.min.x + 28.0, r.center().y), Align2::LEFT_CENTER, label, regular(12.0), col); + let text_rect = Rect::from_min_max(pos2(r.min.x + 28.0 + indent, r.min.y), pos2(r.max.x - 8.0, r.max.y)); + ui.painter().with_clip_rect(text_rect).text(pos2(text_rect.min.x, r.center().y), Align2::LEFT_CENTER, label, regular(12.0), col); if !shortcut.is_empty() { ui.painter().text( pos2(r.max.x - 10.0, r.center().y), @@ -318,6 +329,9 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { 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| { + if select_layer_menu(app, ui, pos, &t) { + close = true; + } let has_sel = !ids.is_empty(); let entries: &[(&str, &str)] = if has_sel { &[ @@ -367,10 +381,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { }; for (label, id) in entries { if *label == "-" { - ui.add_space(4.0); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); - ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, Color32::from_white_alpha(30))); - ui.add_space(4.0); + separator(ui); continue; } let sc = figcraft_engine::catalog::shortcut(id).unwrap_or(""); @@ -393,12 +404,69 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { let clicked_outside = ctx.input(|i| i.pointer.any_pressed()) && !area.response.hovered() && !over_menu(ctx); if close || clicked_outside || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.context_menu = None; + ctx.data_mut(|d| d.remove::(select_layer_id())); } } crate::colorpicker::show(app, ctx); palette(app, ctx); } +fn select_layer_id() -> Id { + Id::new("ctx_select_layer_open") +} + +/// Open (or close) the context menu's Select layer submenu. +pub fn open_select_layer(ctx: &egui::Context, open: bool) { + ctx.data_mut(|d| d.insert_temp(select_layer_id(), open)); +} + +/// The context menu's "Select layer" submenu (right-click on the canvas): every layer under the +/// click, nested ones indented, the selected ones checked. Returns whether one was picked. +fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) -> bool { + if !app.canvas.rect.contains(pos) { + return false; + } + 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(); + if layers.is_empty() { + return false; + } + let sel = ds.selection.clone(); + let resp = submenu_item(ui, "Select layer", t); + if resp.hovered() { + ui.data_mut(|d| d.insert_temp(select_layer_id(), true)); + } + let mut picked = None; + if ui.data(|d| d.get_temp::(select_layer_id())).unwrap_or(false) { + let at = pos2(resp.rect.max.x + 4.0, resp.rect.min.y - 8.0); + egui::Area::new(Id::new("ctx_select_layer")).fixed_pos(at).order(egui::Order::Foreground).constrain(true).show(ui.ctx(), |ui| { + menu_frame(ui, t, |ui| { + for (id, depth, name) in &layers { + if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) { + picked = Some(*id); + } + } + }); + }); + } + separator(ui); + match picked { + Some(id) => { + app.run("select.set", json!({"ids": [id]})); + true + } + None => false, + } +} + /// The Actions palette: fuzzy search over every command. fn palette(app: &mut FigApp, ctx: &egui::Context) { let Some(mut q) = app.ui.palette.clone() else { return }; diff --git a/crates/ui-egui/src/scale_panel.rs b/crates/ui-egui/src/scale_panel.rs index 3a1c26d9..9c00c7f5 100644 --- a/crates/ui-egui/src/scale_panel.rs +++ b/crates/ui-egui/src/scale_panel.rs @@ -1,6 +1,7 @@ //! The Scale tool's panel (K): scale the selection by a factor or to a width or height, around an -//! anchor point. Everything goes through `transform.scale`, so strokes, effects, corner radii and -//! text scale with the layers. +//! anchor point (the engine's `tool.scaleAnchor`, which the canvas handles scale about too). +//! Everything goes through `transform.scale`, so strokes, effects, corner radii and text scale +//! with the layers. use egui::{CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_doc::{Document, NodeId}; @@ -11,17 +12,13 @@ use crate::right::row; use crate::theme::Tokens; use crate::widgets::{self, FieldEdit, Lead}; -fn anchor_id() -> Id { - Id::new("scale_anchor") -} - /// The Scale section, shown in place of Position while the Scale tool is active. pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let Some(b) = doc.bounds_of(sel) else { return }; widgets::section_header(ui, "Scale", &[], false); let col = widgets::col_width(ui.available_width()); // Anchor: column and row (0 start, 1 middle, 2 end) of the point that stays put. - let anchor: (u8, u8) = ui.data(|d| d.get_temp(anchor_id())).unwrap_or((0, 0)); + let anchor = app.session.tool.scale_anchor; let mut factor: Option = None; let round = |v: f64| (v * 100.0).round() / 100.0; widgets::sublabels(ui, "Dimensions", None); @@ -44,7 +41,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } ui.add_space(8.0); if let Some(a) = anchor_picker(ui, anchor) { - ui.data_mut(|d| d.insert_temp(anchor_id(), a)); + app.run("tool.scaleAnchor", json!({"anchor": figcraft_engine::tools::anchor_name(a)})); } }); if let Some((k, cx, cy)) = factor.and_then(|k| scale_about(b, anchor, k)) { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index eb85e0ee..387f904a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -120,7 +120,7 @@ and its release (or a double-click's two clicks) as separate calls. Returns `{qu Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`. -### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?}` +### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?}` Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`, `home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool), `iconGallery` (bool: @@ -137,7 +137,10 @@ profile chosen; `null` closes it), `stylePicker` (`fill|stroke|effect|grid|text` picker for the selection, `text` being the text style browser of the Typography section; `null` closes it) with `stylePickerEdit` (a style id or name: open its editor), `stylePickerView` (`list|grid`), `protoEditor` (`[layer id, interaction index]`: open that interaction's editor in the -Prototype panel; `null` closes it). Comments: `commentOpen` (a thread id opens its popover, `null` +Prototype panel; `null` closes it), `contextMenu` (`[sx, sy]`: right-click the canvas at that screen +point, selecting what is under it (with `cmd: true`, the nested layer) and opening the context menu; +`null` closes it), `contextSelectLayer` (bool: open the context menu's Select layer submenu, which +lists every layer under the click). Comments: `commentOpen` (a thread id opens its popover, `null` closes it), `commentDraft` (`{x, y}`: start a new comment there), `commentFilter` (`open|resolved|mine|unread|all`: what the Comments panel and the pins show), `commentSort` (`date|page`), `commentSearch` (the panel's search text), `commentReply` (the open thread's reply diff --git a/docs/mcp.md b/docs/mcp.md index 9615df78..52124e1b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -374,6 +374,31 @@ and a file keeps the newest 20 autosaves (user versions are never dropped for th coordinates: they move with the frame, span only it, are copied with it and snap the layers inside it. In the app, a guide dragged out of a ruler and dropped on a frame becomes that frame's guide. +### Measuring, the Scale tool and selecting + +- **Measure** (what Alt/Option-hover shows): `measure.distances {ids? (default the selection), to? + (a layer; default the selection's parent)}` returns `{lines: [{x0, y0, x1, y1, label, dashed}]}` + in page coordinates, between bounding boxes (several layers by their union, rotated ones by + their bounds): one box inside the other measures to each edge, boxes apart measure the gaps + (`dashed` lines extend a gap line to a box it passes beside), overlapping boxes measure between + matching edges. `measure.padding {id? (an auto layout frame or a child)}` returns `{frame, lines}`: + the frame's padding and the gaps between neighbouring children (what Shift+Alt-hover shows). + With `pointer`, `alt: true` hovers measure (add `cmd` for nested layers, `shift` for padding) and + `alt` drags show live distances to the neighbours; `inspect_ui` reports them under + `tool.overlay` (`guides`, and `measure_box` for the box measured to). +- **Scale tool** (`tool.scale`, K): dragging a selection handle with `pointer` scales everything by + one factor (sizes and positions whatever the constraints, strokes, effects, corner radii, auto + layout spacing, min/max sizes, layout grids, guides, tiled images, text), one undo step per drag, + about the anchor point `tool.scaleAnchor {anchor?: tl|t|tr|l|c|r|bl|b|br}` (or, when the anchor + lies on the side being dragged, the opposite handle; `alt: true` scales from the centre), with the + factor and pivot in `tool.overlay.scale` while dragging. Without + dragging, `transform.scale {ids?, factor, cx?, cy?}` does the same numerically. +- **Select**: `select.layersAt {x, y}` lists every layer under a page point, front-most first with its + 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. + 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/parity.md b/docs/parity.md index 4ed0a4cc..ef3f3ee3 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -1,6 +1,6 @@ # Parity report -Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (407 commands in the engine). +Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (411 commands in the engine). **197 / 202 menu items (97.5%)**