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%)**