From 2e7790b58baea5896e20514730b75d5b9cb2f08b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:20:45 +0000 Subject: [PATCH] refactor(engine): split canvas/tools.rs into tools/ --- crates/engine/src/canvas/tools.rs | 2335 +-------------------- crates/engine/src/canvas/tools/click.rs | 344 +++ crates/engine/src/canvas/tools/drag.rs | 605 ++++++ crates/engine/src/canvas/tools/draw.rs | 229 ++ crates/engine/src/canvas/tools/handles.rs | 426 ++++ crates/engine/src/canvas/tools/keys.rs | 174 ++ crates/engine/src/canvas/tools/pen.rs | 276 +++ crates/engine/src/canvas/tools/pointer.rs | 292 +++ 8 files changed, 2365 insertions(+), 2316 deletions(-) create mode 100644 crates/engine/src/canvas/tools/click.rs create mode 100644 crates/engine/src/canvas/tools/drag.rs create mode 100644 crates/engine/src/canvas/tools/draw.rs create mode 100644 crates/engine/src/canvas/tools/handles.rs create mode 100644 crates/engine/src/canvas/tools/keys.rs create mode 100644 crates/engine/src/canvas/tools/pen.rs create mode 100644 crates/engine/src/canvas/tools/pointer.rs diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs index 424694b3..fce14f7c 100644 --- a/crates/engine/src/canvas/tools.rs +++ b/crates/engine/src/canvas/tools.rs @@ -19,6 +19,25 @@ use crate::shape_handles::{ShapeHandle, ShapeHint}; use crate::snap::{Guide, Targets}; use crate::{Result, Session, textedit}; +mod click; +mod drag; +mod draw; +mod handles; +mod keys; +mod pen; +mod pointer; + +use handles::DRAG_SLOP_PX; +#[cfg(test)] +pub(crate) use handles::scale_factor; +pub use handles::{ + PENCIL_STROKE, SMALL_BOX_PX, anchor_name, anchor_of, box_sides_px, handle_at, handle_at_corners, label_at, locked_in, scale_anchor_point, + selection_box, selection_frame, +}; +pub(crate) use handles::{SNAP_PX, handle_cursor, readable_text_fills, resized_box}; +#[cfg(test)] +pub(crate) use pen::pressure_profile; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Default)] pub enum ToolId { #[default] @@ -552,2324 +571,8 @@ pub struct ToolState { pub picked: Option, } -/// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most -/// 32 points, medium pressure (0.5) at the stroke weight, from a tenth to twice it. `None` when no -/// sample has pressure. -pub(crate) fn pressure_profile(points: &[Point], pressure: &[Option]) -> Option> { - if pressure.iter().all(Option::is_none) { - return None; - } - let mut lengths = vec![0.0]; - for w in points.windows(2) { - let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; - lengths.push(lengths.last().copied().unwrap_or(0.0) + (*b - *a).hypot()); - } - let total = lengths.last().copied().unwrap_or(0.0); - if !(total.is_finite() && total > 0.0) { - return None; - } - let at = |i: usize| f64::from(pressure.get(i).copied().flatten().filter(|p| p.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0)); - let n = points.len().clamp(2, 32); - let profile = (0..n).map(|k| { - let t = k as f64 / (n - 1) as f64; - let s = t * total; - let i = lengths.partition_point(|l| *l < s).min(lengths.len().saturating_sub(1)); - let p = match (i.checked_sub(1).and_then(|j| lengths.get(j)), lengths.get(i)) { - (Some(a), Some(b)) if b > a => at(i - 1) + (at(i) - at(i - 1)) * ((s - a) / (b - a)), - _ => at(i), - }; - [t, (p / 0.5).clamp(0.1, 2.0)] - }); - Some(profile.collect()) -} - -/// A frame or section drawn around layers takes those of its siblings that lie fully inside it -/// (visible and unlocked ones, in their order; a frame never takes a section). Not in auto layout, -/// where the new frame is one more item of the flow. -fn take_enclosed(doc: &mut Document, drawn: NodeId) -> Result<()> { - let Some(n) = doc.node(drawn) else { return Ok(()) }; - let section = matches!(n.kind, NodeKind::Section { .. }); - let (Some(parent), Some(area)) = (n.parent.and_then(|p| doc.node(p)), doc.world_bounds(drawn)) else { return Ok(()) }; - if parent.layout.is_some() { - return Ok(()); - } - let inside: Vec = parent - .children - .iter() - .copied() - .filter(|c| *c != drawn && doc.node(*c).is_some_and(|k| k.visible && !k.locked && (section || !matches!(k.kind, NodeKind::Section { .. })))) - .filter(|c| doc.world_bounds(*c).is_some_and(|b| area.contains(b.origin()) && area.contains(Point::new(b.x1, b.y1)))) - .collect(); - for c in inside { - doc.reparent(c, drawn, None)?; - } - Ok(()) -} - -/// The frame whose name label (see [`ToolState::labels`]) is under `p` on `page`: the topmost -/// one drawn, if it can be selected (still there, on the page, visible and unlocked). -pub fn label_at(doc: &Document, page: NodeId, labels: &[(NodeId, [Point; 4])], p: Point) -> Option { - labels - .iter() - .rev() - .find(|(id, q)| in_quad(q, p) && doc.page_of(*id) == Some(page) && doc.node(*id).is_some_and(|n| n.visible && !n.locked)) - .map(|(id, _)| *id) -} - -/// Whether `p` is inside the convex quadrilateral `q` (either winding; edges included). -fn in_quad(q: &[Point; 4], p: Point) -> bool { - let (mut left, mut right) = (false, false); - for (a, b) in q.iter().zip(q.iter().cycle().skip(1)) { - let side = (*b - *a).cross(p - *a); - if !side.is_finite() { - return false; - } - left |= side < 0.0; - right |= side > 0.0; - } - // On one side of every edge (a point can't be on all four edges of a quad with an area). - left != right -} - -/// 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") -} - -/// The fill new text takes to be readable where it is drawn, in `parent`: white on a dark -/// background (None: the model's default, black). The background is the nearest layer from `parent` -/// out whose top visible fill is a mostly opaque solid colour, else the page (#362). For text made -/// in the app: by the Text tool, or by a command that names no fill while the interface is up. -pub(crate) fn readable_text_fills(doc: &Document, parent: NodeId) -> Option> { - let dark = std::iter::once(parent).chain(doc.ancestors(parent)).filter_map(|id| doc.node(id)).find_map(|n| match &n.kind { - NodeKind::Page { background, .. } => Some(background.is_dark()), - _ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| p.solid_color()).filter(|c| c.a >= 0.5).map(|c| c.is_dark()), - }); - dark.unwrap_or(false).then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]) -} - -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) } } - -const HANDLE_PX: f64 = 5.0; -const ROTATE_PX: f64 = 18.0; -/// Snap distance in screen pixels. -pub(crate) const SNAP_PX: f64 = 5.0; -/// Screen pixels the pointer travels after a press before a move or resize starts: a click or a -/// double-click made with an unsteady hand doesn't drag what it lands on. -const DRAG_SLOP_PX: f64 = 3.0; -/// Screen pixels from a path within which the Text on a path tool takes it. -const PATH_REACH_PX: f64 = 6.0; - -/// The visible layers under `id` whose box, grown by `reach` (page units), holds `p`, front-most -/// first: what the Text on a path tool measures its distance to. Content a clipping frame or a -/// mask hides at `p` and the operands of boolean groups are left out. -fn path_candidates(doc: &Document, id: NodeId, p: Point, reach: f64, depth: usize, out: &mut Vec) { - let Some(n) = doc.node(id) else { return }; - if !n.visible || depth > 512 || matches!(n.kind, NodeKind::BooleanOp { .. }) { - return; - } - if n.clip && n.kind.is_frame_like() && !n.local_rect().contains(doc.world_transform(id).inverse() * p) { - return; - } - for c in n.paint_order().rev() { - let before = out.len(); - path_candidates(doc, c, p, reach, depth + 1, out); - if out.len() > before && figcraft_doc::geometry::masked_out(doc, n, c, p) { - out.truncate(before); - } - } - if n.children.is_empty() && !n.is_mask && doc.world_bounds(id).is_some_and(|b| b.inflate(reach, reach).contains(p)) { - out.push(id); - } -} - -/// Corners of the selection's handle box in page coordinates (tl, tr, br, bl). -pub fn selection_box(doc: &Document, sel: &[NodeId]) -> Option<[Point; 4]> { - let (f, w, h) = selection_frame(doc, sel)?; - Some([f * Point::ZERO, f * Point::new(w, 0.0), f * Point::new(w, h), f * Point::new(0.0, h)]) -} - -/// The selection's handle box: the page transform of its top-left corner (axes along the box) and -/// its size. One layer: its own box; several sharing a rotation: a box turned with them; -/// otherwise the page-aligned bounds. -pub fn selection_frame(doc: &Document, sel: &[NodeId]) -> Option<(Affine, f64, f64)> { - let ids = roots(doc, sel); - if let [id] = ids.as_slice() { - let n = doc.node(*id)?; - return Some((doc.world_transform(*id), n.width, n.height)); - } - let Some(angle) = shared_rotation(doc, &ids) else { - let b = doc.bounds_of(&ids)?; - return Some((Affine::translate((b.x0, b.y0)), b.width(), b.height())); - }; - let rot = Affine::rotate(angle); - let inv = rot.inverse(); - let b = ids - .iter() - .filter_map(|i| Some(figcraft_geom::transform_rect_bbox(inv * doc.world_transform(*i), doc.node(*i)?.local_rect()))) - .reduce(|a, b| a.union(b))?; - Some((rot * Affine::translate((b.x0, b.y0)), b.width(), b.height())) -} - -/// The rotation (radians) every layer in `ids` shares, unless it's a multiple of 90 degrees (the -/// page-aligned box is then the same box). Flipped layers count by their axis, not its direction. -fn shared_rotation(doc: &Document, ids: &[NodeId]) -> Option { - use std::f64::consts::{FRAC_PI_2, PI}; - const EPS: f64 = 1e-4; - let mut angles = ids.iter().map(|i| { - let c = doc.world_transform(*i).as_coeffs(); - c[1].atan2(c[0]) - }); - let a = angles.next()?; - let same = |b: f64| { - let d = (b - a).rem_euclid(PI); - d.min(PI - d) < EPS - }; - let axis_aligned = (a - (a / FRAC_PI_2).round() * FRAC_PI_2).abs() < EPS; - (a.is_finite() && !axis_aligned && angles.all(same)).then_some(a) -} - -/// Whether layer `id` is locked or inside a locked layer: it can be selected in the layers panel -/// but not moved or resized on the canvas. -pub fn locked_in(doc: &Document, id: NodeId) -> bool { - std::iter::once(id).chain(doc.ancestors(id)).any(|a| doc.node(a).is_some_and(|n| n.locked)) -} - -/// Whether layer `t` paints below layer `s`: it holds `s`, or it is behind `s` in the stack of the -/// nearest layer holding both. -fn paints_below(doc: &Document, t: NodeId, s: NodeId) -> bool { - let chain = |id: NodeId| { - let mut c: Vec = std::iter::once(id).chain(doc.ancestors(id)).collect(); - c.reverse(); - c - }; - let (ct, cs) = (chain(t), chain(s)); - let shared = ct.iter().zip(&cs).take_while(|(a, b)| a == b).count(); - match (ct.get(shared), cs.get(shared)) { - (None, Some(_)) => true, - (Some(a), Some(b)) => shared.checked_sub(1).and_then(|i| ct.get(i)).and_then(|p| doc.node(*p)).is_some_and(|p| { - let order: Vec = p.paint_order().collect(); - order.iter().position(|c| c == a) < order.iter().position(|c| c == b) - }), - _ => false, - } -} - -/// Which handle (if any) is under `p`. A selection with a locked layer has none. -pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option { - if sel.is_empty() || sel.iter().any(|i| locked_in(doc, *i)) { - return None; - } - // A connector's handles are its ends (see `board::end_handle_at`): no box to resize or turn. - if crate::board::selected_connector_ends(doc, sel).is_some() { - return None; - } - let corners = selection_box(doc, sel)?; - // Lines (zero height) only get end handles. - let is_line = sel.len() == 1 && sel.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Line)); - handle_at_corners(&corners, p, zoom, is_line) -} - -/// Half the side of the square the canvas draws on each corner of the handle box (screen pixels, -/// upright whatever the box's rotation). -const CORNER_SQUARE_PX: f64 = 4.0; - -/// Whether `p` is on a corner square of the selection's handle box as drawn: there the corner -/// resizes, although a corner-radius handle 10 px in reaches that far too. -fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool { - let reach = CORNER_SQUARE_PX / zoom.max(1e-6); - matches!(handle_at(doc, sel, p, zoom), Some(Handle::Corner(_))) - && selection_box(doc, sel).is_some_and(|c| c.iter().any(|c| (c.x - p.x).abs() <= reach && (c.y - p.y).abs() <= reach)) -} - -/// A box whose sides are both shorter than this on screen (pixels) would be covered by its -/// handles: the canvas draws just its outline (the handles still answer just outside it). -pub const SMALL_BOX_PX: f64 = 16.0; - -/// The stroke the Pencil draws: its colour and weight (the canvas previews it the same). -pub const PENCIL_STROKE: (figcraft_color::Color, f64) = (figcraft_color::Color::BLACK, 2.0); - -/// The shorter and longer sides, in screen pixels, of the box with `corners` (tl, tr, br, bl). -pub fn box_sides_px(corners: &[Point; 4], zoom: f64) -> (f64, f64) { - let [a, b, _, d] = corners; - let (w, h) = ((*b - *a).hypot() * zoom, (*d - *a).hypot() * zoom); - (w.min(h), w.max(h)) -} - -/// Which handle of the box with `corners` (tl, tr, br, bl in page coordinates) is under `p`; -/// `is_line` keeps only the two end handles. -pub fn handle_at_corners(corners: &[Point; 4], p: Point, zoom: f64, is_line: bool) -> Option { - let tol = HANDLE_PX / zoom.max(1e-6); - // Inside the box, handles reach in at most a quarter of its shorter side, so that a press in - // the middle of a small layer moves it; outside it they reach as far as ever. - let inner = if !is_line && in_quad(corners, p) { box_sides_px(corners, zoom).0 / 4.0 / zoom.max(1e-6) } else { f64::INFINITY }; - let (corner_reach, edge_reach) = ((tol * 1.6).min(inner), tol.min(inner)); - // The nearest corner, then the nearest edge, in reach (a small box's reaches overlap). - let nearest = |hits: &mut dyn Iterator| hits.min_by(|a, b| a.0.total_cmp(&b.0)).map(|(_, h)| h); - let corner = nearest( - &mut corners - .iter() - .enumerate() - .filter(|(i, _)| !(is_line && *i >= 2)) - .map(|(i, c)| ((*c - p).hypot(), Handle::Corner(i as u8))) - .filter(|(d, _)| *d <= corner_reach), - ); - if corner.is_some() { - return corner; - } - if !is_line { - use figcraft_geom::kurbo::ParamCurveNearest; - let edge = nearest(&mut (0..4).filter_map(|i| { - let (a, b) = (corners.get(i)?, corners.get((i + 1) % 4)?); - let near = figcraft_geom::kurbo::Line::new(*a, *b).nearest(p, 0.001); - let d = near.distance_sq.sqrt(); - (d <= edge_reach && near.t > 0.05 && near.t < 0.95).then_some((d, Handle::Edge(i as u8))) - })); - if edge.is_some() { - return edge; - } - } - // Rotation zones: just outside each corner. - let center = Point::new(corners.iter().map(|c| c.x).sum::() / 4.0, corners.iter().map(|c| c.y).sum::() / 4.0); - let rot = ROTATE_PX / zoom.max(1e-6); - for (i, c) in corners.iter().enumerate() { - let d = (*c - p).hypot(); - let outward = (p - *c).dot(*c - center) > 0.0; - if d <= rot && d > tol && outward { - return Some(Handle::Rotate(i as u8)); - } - } - None -} - -fn resize_cursor(doc: &Document, sel: &[NodeId], h: Handle) -> Cursor { - selection_box(doc, sel).map_or(Cursor::Default, |c| handle_cursor(&c, h)) -} - -/// The cursor over handle `h` of the box with corners `c`. -pub(crate) fn handle_cursor(c: &[Point; 4], h: Handle) -> Cursor { - let angle = |a: Point, b: Point| (b - a).atan2().to_degrees(); - match h { - Handle::Corner(i) => { - let a = c.get(i as usize).copied().unwrap_or_default(); - let b = c.get((i as usize + 2) % 4).copied().unwrap_or_default(); - // Along the box's own axes at 45°, not the diagonal, so wide or tall boxes still get a - // diagonal cursor. A box with no width or height falls back to the diagonal. - let unit = |v: figcraft_geom::kurbo::Vec2| { - let len = v.hypot(); - (len.is_finite() && len > 1e-9).then(|| v / len) - }; - let next = c.get((i as usize + 1) % 4).copied().unwrap_or_default(); - let prev = c.get((i as usize + 3) % 4).copied().unwrap_or_default(); - match (unit(next - a), unit(prev - a)) { - (Some(u), Some(v)) => Cursor::Resize((u + v).atan2().to_degrees()), - _ => Cursor::Resize(angle(a, b)), - } - } - Handle::Edge(i) => { - let a = c.get(i as usize).copied().unwrap_or_default(); - let b = c.get((i as usize + 1) % 4).copied().unwrap_or_default(); - Cursor::Resize(angle(a, b) + 90.0) - } - Handle::Rotate(i) => { - let centre = Point::new(c.iter().map(|p| p.x).sum::() / 4.0, c.iter().map(|p| p.y).sum::() / 4.0); - Cursor::Rotate(angle(centre, c.get(i as usize).copied().unwrap_or_default())) - } - } -} - -/// The box (x0, y0, x1, y1) a `w0`×`h0` handle box becomes when `handle` is dragged to `q`, all in -/// the box's own axes: `keep` constrains proportions, `from_center` resizes about the centre (the -/// opposite side mirrors the dragged one). Edges that cross over come back reversed (x1 < x0 or -/// y1 < y0: a flip). -pub(crate) fn resized_box(handle: Handle, q: Point, w0: f64, h0: f64, keep: bool, from_center: bool) -> (f64, f64, f64, f64) { - let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, w0, h0); - // The dragged sides: x0 or x1 (`Some(false)` / `Some(true)`), y0 or y1. - let (sx, sy) = match handle { - Handle::Corner(0) => (Some(false), Some(false)), - Handle::Corner(1) => (Some(true), Some(false)), - Handle::Corner(2) => (Some(true), Some(true)), - Handle::Corner(_) => (Some(false), Some(true)), - Handle::Edge(0) => (None, Some(false)), - Handle::Edge(1) => (Some(true), None), - Handle::Edge(2) => (None, Some(true)), - Handle::Edge(_) => (Some(false), None), - Handle::Rotate(_) => (None, None), - }; - match sx { - Some(false) => (x0, x1) = (q.x, if from_center { w0 - q.x } else { w0 }), - Some(true) => (x0, x1) = (if from_center { w0 - q.x } else { 0.0 }, q.x), - None => {} - } - match sy { - Some(false) => (y0, y1) = (q.y, if from_center { h0 - q.y } else { h0 }), - Some(true) => (y0, y1) = (if from_center { h0 - q.y } else { 0.0 }, q.y), - None => {} - } - if keep && w0 > 1e-9 && h0 > 1e-9 && !matches!(handle, Handle::Rotate(_)) { - // One factor for both axes: the dragged axis's (a corner: the one that moved the most), - // about the point that stays put (the opposite handle, or the centre). - let (kx, ky) = ((x1 - x0) / w0, (y1 - y0) / h0); - let k = match (sx, sy) { - (None, _) => ky, - (_, None) => kx, - _ if kx.abs() > ky.abs() => kx, - _ => ky, - }; - let pivot = if from_center { Point::new(w0 / 2.0, h0 / 2.0) } else { handle_point(opposite(handle), w0, h0) }; - (x0, x1) = (pivot.x * (1.0 - k), pivot.x + (w0 - pivot.x) * k); - (y0, y1) = (pivot.y * (1.0 - k), pivot.y + (h0 - pivot.y) * k); - } - (x0, y0, x1, y1) -} - -/// 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 { - /// The pointer left the canvas: what hovering there showed goes (the distances Alt or Dev Mode - /// measured and the box measured to, a vector region's highlight), unless a gesture holds it. - pub fn pointer_left(&mut self) { - if !self.tool.busy() { - self.tool.overlay.guides.clear(); - self.tool.overlay.measure_box = None; - } - self.vector_pointer_left(); - } - - /// What a marquee starting at page point `p` selects inside: the top-level frame it starts in - /// (its children), or the focus view's frame. - pub(crate) fn marquee_within(&self, p: Point) -> Result> { - let (doc, page) = (self.doc()?, self.page()?); - let within = frame_at(doc, page, p, &[]).filter(|f| doc.node(*f).is_some_and(|n| n.parent == Some(page))); - let within = within.filter(|f| doc.world_bounds(*f).is_some_and(|b| b.contains(p))); - Ok(self.ds()?.focused().or(within)) - } - - /// The selection a marquee over page rect `r` makes from `base` (what was selected before): - /// the layers it touches (`deep`: nested ones too); `additive` (Shift) toggles them. - pub(crate) fn marquee_selection(&self, r: Rect, within: Option, deep: bool, base: &[NodeId], additive: bool) -> Result> { - let (doc, page) = (self.doc()?, self.page()?); - let mut hits = - if deep { figcraft_doc::geometry::marquee_deep(doc, page, r) } else { figcraft_doc::geometry::marquee_select(doc, page, r, within) }; - if let Some(f) = self.ds()?.focused() { - hits.retain(|h| *h != f && doc.is_ancestor(f, *h)); - } - // A marquee over a frame from outside selects the frame only when it covers it fully. - if within.is_none() && !deep { - hits.retain(|h| { - let full = doc.world_bounds(*h).is_some_and(|b| r.contains(b.origin()) && r.contains(Point::new(b.x1, b.y1))); - full || !doc.node(*h).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) - }); - } - // Shift toggles: what was selected and is touched again comes out. - let mut sel: Vec = base.iter().copied().filter(|b| !(additive && hits.contains(b))).collect(); - for h in hits { - if !base.contains(&h) && !sel.contains(&h) { - sel.push(h); - } - } - Ok(sel) - } - - pub fn set_tool(&mut self, t: ToolId) { - if self.tool.tool == ToolId::Pen && t != ToolId::Pen { - let _ = self.pen_finish(); - } - if !matches!(t, ToolId::Move | ToolId::Scale | ToolId::Hand) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) { - let _ = crate::vectoredit::end(self); - } - self.tool.tool = t; - self.tool.gesture = Gesture::None; - self.tool.pick_only = false; - // The tool's cursor shows before the pointer moves. - self.tool.overlay = - Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, cursor: t.cursor(), ..Overlay::default() }; - self.tool.shape_builder = None; - if t == ToolId::ShapeBuilder { - // Regions show straight away, before the pointer moves. - let sel = self.selection(); - let _ = crate::shapebuilder::arrangement(self, &sel); - } - if let Ok(ds) = self.ds_mut() { - if !matches!(t, ToolId::Text | ToolId::TextOnPath | ToolId::Move) { - ds.text_edit = None; - } - ds.revision += 1; - } - } - - /// The tool pointer events go to: in Dev Mode every tool but the hand and comments selects. - fn acting_tool(&self) -> ToolId { - match self.tool.tool { - t if self.tool.dev_mode && !matches!(t, ToolId::Hand | ToolId::Comment) => ToolId::Move, - t => t, - } - } - - /// Drop the gesture under way (a drag, a resize, a shape being drawn), restoring the document - /// as Escape does; the UI calls it when Dev Mode starts, where nothing may move. - pub fn drop_gesture(&mut self) -> Result<()> { - self.tool.gesture = Gesture::None; - self.cancel_interaction() - } - - /// Feed one pointer event to the current tool. - pub fn pointer(&mut self, ev: &PointerEvent) -> Result<()> { - // A held button sends the same move every frame: running the gesture again would only - // rebuild the same preview (a new document, and a canvas render, every frame). - if ev.kind != PointerKind::Move { - self.tool.last_move = None; - } else if self.tool.busy() { - if self.tool.last_move.as_ref() == Some(ev) { - return Ok(()); - } - self.tool.last_move = Some(ev.clone()); - } - let tool = self.acting_tool(); - let edits = matches!(tool, ToolId::Move | ToolId::Scale) && !self.tool.dev_mode; - if edits && self.origin_pointer(ev)? { - return Ok(()); - } - if edits && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? { - return Ok(()); - } - if tool == ToolId::ShapeBuilder { - return self.shape_builder_pointer(ev); - } - match ev.kind { - PointerKind::Hover => self.hover(ev), - PointerKind::Down => match tool { - ToolId::Move | ToolId::Scale => { - // While a board's vote runs, a click on an object votes. - if tool == ToolId::Move && !self.tool.dev_mode && self.vote_down(ev)? { - return Ok(()); - } - // A click on the canvas deselects a ruler guide (the UI selects guides itself). - self.ds_mut()?.guide = None; - self.move_down(ev) - } - ToolId::Hand => { - let v = &self.ds()?.view; - self.tool.gesture = Gesture::Pan { sx: ev.sx, sy: ev.sy, cx: v.cx, cy: v.cy }; - self.tool.overlay.cursor = Cursor::Grabbing; - Ok(()) - } - ToolId::Text => self.text_down(ev), - ToolId::TextOnPath => self.text_on_path_down(ev), - ToolId::Pen => self.pen_down(ev), - ToolId::Pencil => { - self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; - Ok(()) - } - ToolId::Comment => { - self.request_ui("comment.new", json!({"x": ev.x, "y": ev.y})); - Ok(()) - } - ToolId::Eyedropper => self.eyedrop(ev), - t if t.on_boards() => self.board_down(ev), - t if t.draws_shape() => self.draw_down(ev), - _ => Ok(()), - }, - PointerKind::Move => self.drag(ev), - PointerKind::Up => self.up(ev), - PointerKind::DoubleClick => self.double_click(ev), - } - } - - /// The rotation origin handle while it shows: a press drags it, a double-click puts it back - /// at the centre. True when the event was the handle's. - fn origin_pointer(&mut self, ev: &PointerEvent) -> Result { - let dragging = matches!(self.tool.gesture, Gesture::Origin); - if !dragging && !self.ds()?.on_origin_handle(ev.p(), ev.zoom) { - return Ok(false); - } - match ev.kind { - PointerKind::Down if !dragging => self.tool.gesture = Gesture::Origin, - PointerKind::Move if dragging => self.drag_origin_to(ev.p(), ev.zoom)?, - PointerKind::Up if dragging => self.tool.gesture = Gesture::None, - PointerKind::DoubleClick => self.set_rotation_origin(None)?, - PointerKind::Hover => {} - _ => return Ok(false), - } - self.tool.overlay.cursor = Cursor::Move; - Ok(true) - } - - fn hover(&mut self, ev: &PointerEvent) -> Result<()> { - let tool = self.acting_tool(); - let ds = self.ds()?; - let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); - let mut cursor = tool.cursor(); - // The text being edited, and with the Text tool any text, takes the I-beam: a click there - // puts the caret. - let on_edited = ds - .text_edit - .as_ref() - .is_some_and(|te| doc.world_bounds(te.id).is_some_and(|b| b.inflate(ev.tol() * 4.0, ev.tol() * 4.0).contains(ev.p()))); - let on_text = - |s: &Self| s.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|l| doc.node(l)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))); - if tool == ToolId::Text && (on_edited || on_text(self)) { - cursor = Cursor::Text; - } - if tool == ToolId::TextOnPath && (on_edited || self.path_at(&doc, page, ev).is_some()) { - cursor = Cursor::Text; - } - let mut hover = None; - let mut guides = Vec::new(); - let mut measure_box = None; - let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None }; - self.tool.overlay.arc_hover = arc_hover; - let shape_hover = if self.arc_handles_shown() && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) { - crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) - } else { - None - }; - self.tool.overlay.shape_hover = shape_hover; - if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited { - cursor = Cursor::Text; - } else if matches!(tool, ToolId::Move | ToolId::Scale) && arc_hover.is_none() && shape_hover.is_none() { - if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| !self.tool.dev_mode) { - cursor = resize_cursor(&doc, &sel, h); - } else { - let leaf = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); - if leaf.and_then(|l| crate::widgets::target_at(&doc, l, ev.p())).is_some() { - cursor = Cursor::Pointer; - } - hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))); - // Alt over a selected layer: a drag from here duplicates it. - if ev.alt && !self.tool.dev_mode && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) { - cursor = Cursor::Copy; - } - if ev.alt || self.tool.dev_mode { - 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]); - } - } - } - } - if tool == ToolId::Move && !self.tool.dev_mode && crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom).is_some() { - cursor = Cursor::Grab; - } - 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. - let (to, g) = if ev.shift { (ev.p(), Vec::new()) } else { self.snap_pen_tool(ev)? }; - guides = g; - self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom)); - if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { - // With Shift the band ends where a click puts the point (#970). - let to = if ev.shift { at_45_degrees(*last, to) } else { to }; - self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]); - self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]); - } - } - self.tool.overlay.guides = guides; - let ds = self.ds_mut()?; - if ds.hover != hover { - ds.hover = hover; - ds.revision += 1; - } - Ok(()) - } - - /// What a click with the Pen tool at `p` (where it snaps) would do: close the path on its first - /// point, connect to another of its points, go on from a selected open path's end, or add a - /// new point (the same tests as [`Self::pen_down`]). - fn pen_cursor(&self, doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> PenCursor { - let near = |q: &Point| (*q - p).hypot() * zoom < 8.0; - let on_point = match &self.tool.pen { - None => selected_end_at(doc, sel, p, 8.0 / zoom.max(1e-6)).is_some(), - Some(pen) => { - let n = pen.points.len(); - (n > 2 && pen.points.first().is_some_and(|(q, _, _)| near(q))) - || (1..n.saturating_sub(1)).any(|k| pen.points.get(k).is_some_and(|(q, _, _)| near(q))) - } - }; - if on_point { PenCursor::Close } else { PenCursor::New } - } - - /// Whether what the gesture did to `ids` so far is an edit only a main component can make - /// (`moved`: their place among the layers counts too): it is undone when the gesture ends. - fn refused(&self, ids: &[NodeId], moved: bool) -> bool { - let Ok(ds) = self.ds() else { return false }; - ds.interaction.as_ref().is_some_and(|it| { - ids.iter().any(|m| { - (moved && !crate::components::move_allowed(&it.doc, &ds.doc, *m)) - || crate::components::refused_override(&it.doc, &ds.doc, *m).is_some() - }) - }) - } - - fn move_down(&mut self, ev: &PointerEvent) -> Result<()> { - let ds = self.ds()?; - let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); - // Clicking outside the text being edited ends editing. - if let Some(te) = ds.text_edit.clone() { - let inside = doc.world_bounds(te.id).is_some_and(|b| b.inflate(ev.tol() * 4.0, ev.tol() * 4.0).contains(ev.p())); - if inside { - return self.text_click(te.id, ev, ev.shift); - } - textedit::end(self)?; - } - // Handles first: a selected connector's ends, a selected ellipse's arc handles (drawn over - // the box), then the box's. - let edits = !self.tool.dev_mode; - if edits && let Some((id, from)) = crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom) { - self.tool.gesture = Gesture::Reattach { id, from, start: ev.p() }; - return Ok(()); - } - if self.arc_handles_shown() - && let Some((id, handle)) = crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom) - && let Some(arc) = crate::arc_handles::arc_of(&doc, id) - { - self.begin_interaction("Arc")?; - self.tool.gesture = Gesture::Arc { id, handle, arc }; - self.tool.overlay.arc = - crate::arc_handles::handle_at(&doc, id, handle, arc, ev.zoom).map(|at| ArcHint { handle, label: arc.label(handle), at }); - return Ok(()); - } - if self.arc_handles_shown() - && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) - && let Some((id, handle)) = crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom) - { - self.begin_interaction("Corner radius")?; - self.tool.gesture = Gesture::Shape { id, handle }; - self.tool.overlay.shape = shape_hint(&doc, id, handle, ev.zoom); - return Ok(()); - } - if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| edits) { - let ids = roots(&doc, &sel); - if let Handle::Rotate(_) = h { - let Some(b) = selection_box(&doc, &ids) else { return Ok(()) }; - let center = self - .ds()? - .moved_origin() - .unwrap_or_else(|| Point::new(b.iter().map(|c| c.x).sum::() / 4.0, b.iter().map(|c| c.y).sum::() / 4.0)); - self.begin_interaction("Rotate")?; - self.tool.gesture = Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), ids }; - return Ok(()); - } - let nodes: Vec<(NodeId, Affine, f64, f64)> = - ids.iter().filter_map(|i| doc.node(*i).map(|n| (*i, doc.world_transform(*i), n.width, n.height))).collect(); - let Some(frame) = selection_frame(&doc, &ids) else { return Ok(()) }; - let single = ids.len() == 1; - self.begin_interaction(if self.tool.tool == ToolId::Scale { "Scale" } else { "Resize" })?; - self.tool.gesture = Gesture::Resize { - start: ev.p(), - r: Box::new(ResizeStart { nodes, frame, single, handle: h }), - scale_tool: self.tool.tool == ToolId::Scale, - moved: false, - }; - return Ok(()); - } - // A frame's name label picks the frame itself. The background of a top-level frame with - // layers in it is like the canvas (a press starts a marquee inside it) unless the frame is - // selected; an empty frame (or a widget, or a board object such as a sticky) is picked and - // dragged. - let board_background = |leaf: NodeId| { - !sel.contains(&leaf) - && doc.node(leaf).is_some_and(|n| { - n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n) && !n.is_board_atom() - }) - }; - let label = label_at(&doc, page, &self.tool.labels, ev.p()); - let target = label.or_else(|| { - self.hit(&doc, page, ev.p(), ev.tol() * 2.0) - .filter(|leaf| ev.cmd || !board_background(*leaf)) - .map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)) - }); - // A press inside the selection's box that reaches nothing above it (an unfilled shape's - // inside, the frame or canvas behind it, or a layer stacked below the selected layer the - // press is in) drags the selection. In a gap between selected layers, what's there is hit. - let in_own_box = |s: NodeId| selection_box(&doc, &[s]).is_some_and(|q| in_quad(&q, ev.p())); - let behind_sel = |t: Option| { - t.is_none_or(|t| sel.iter().any(|s| *s != t && (doc.is_ancestor(t, *s) || (in_own_box(*s) && paints_below(&doc, t, *s))))) - }; - let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) { - Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => { - Some(first) - } - _ => target, - }; - match target { - Some(target) => { - // Clicking inside the current selection's box keeps it (drag them all). - let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift; - // Shift on a selected layer takes it out on release if the press doesn't become a - // drag; a drag moves the selection with it. Where no drag can start, it goes now. - let deselect = (ev.shift && sel.contains(&target)).then_some(target); - let without_target = || sel.iter().copied().filter(|x| *x != target).collect::>(); - let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) { - sel.clone() - } else if ev.shift && deselect.is_none() { - let mut s2 = sel.clone(); - s2.push(target); - s2 - } else if sel.contains(&target) { - sel.clone() - } else { - vec![target] - }; - self.set_selection(new_sel.clone()); - // Dev Mode only selects (Shift-click still deselects): nothing moves. - if !edits { - if deselect.is_some() { - self.set_selection(without_target()); - } - return Ok(()); - } - // Locked layers selected in the layers panel stay put. - let ids: Vec = roots(&doc, &new_sel).into_iter().filter(|i| !locked_in(&doc, *i)).collect(); - if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() { - if deselect.is_some() { - self.set_selection(without_target()); - } - return Ok(()); - } - let mut targets = Targets::gather(&doc, page, &ids); - // A copy dragged out with Alt snaps to the layers it came from too (they stay put). - if ev.alt - && let Some(b) = targets.moving - { - targets.add_box(b); - } - if let Err(e) = self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" }) { - if deselect.is_some() { - self.set_selection(without_target()); - } - return Err(e); - } - self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect }; - self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default }; - } - None => { - let within = self.marquee_within(ev.p())?; - if !ev.shift { - self.set_selection(vec![]); - } - self.tool.gesture = Gesture::Marquee { start: ev.p(), additive: ev.shift, base: if ev.shift { sel } else { vec![] }, within }; - } - } - Ok(()) - } - - fn drag(&mut self, ev: &PointerEvent) -> Result<()> { - 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 } => { - let r = Rect::from_points(*start, ev.p()); - self.tool.overlay.marquee = Some([r.x0, r.y0, r.x1, r.y1]); - // Cmd/Ctrl: the nested layers it touches. - let sel = self.marquee_selection(r, *within, ev.cmd, base, *additive)?; - self.set_selection(sel); - Ok(()) - } - Gesture::Move { start, moved: false, .. } | Gesture::Resize { start, moved: false, .. } if within_slop(ev, *start) => Ok(()), - Gesture::Move { start, ids, targets, duplicate, .. } => { - let mut d = ev.p() - *start; - if ev.shift { - if d.x.abs() > d.y.abs() { - d.y = 0.0; - } else { - d.x = 0.0; - } - } - // Cmd/Ctrl (and Control on macOS) held while dragging suspends snapping. - let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; - let mut guides = Vec::new(); - if snap_on { - let (c, g2) = targets.snap_move(d, SNAP_PX / ev.zoom.max(1e-6)); - d += c; - guides = g2; - } - if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) { - // Keep the box's origin on whole pixels (a rotated parent frame's, inside one). - d = targets.round_move(d); - } - let ids = ids.clone(); - let dup = *duplicate; - let page = self.page()?; - let mut drop_cell = None; - let mut drop_target = None; - let mut in_flow = false; - // Placed in a row or column: (is it a column, its gap) for the insertion line. - let mut slot_axis: Option<(bool, f64)> = None; - self.preview(|doc, sel| { - let mut moving = ids.clone(); - if dup { - let mut copies = Vec::new(); - for i in &ids { - let parent = doc.get(*i)?.parent.unwrap_or(page); - let idx = doc.index_in_parent(*i).map(|x| x + 1); - copies.push(crate::components::copy_in_file(doc, *i, parent, idx)?); - } - // Connectors copied with what they join join the copies. - let map = crate::board::copy_map(doc, &ids, &copies); - crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false); - moving = copies; - *sel = moving.clone(); - } - let p = ev.p(); - let home = moving.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent); - // Absolute children of an auto layout frame stay in it, and so do the layers of - // groups, boolean groups and instances ([`crate::drop::keeps_layers`]); the - // rest may change parent. - let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute)) - && !home.is_some_and(|h| crate::drop::keeps_layers(doc, h)); - let target = if flow { crate::drop::drag_parent(doc, page, p, &moving) } else { home.unwrap_or(page) }; - let mode = doc.node(target).and_then(|n| n.layout.as_ref()).map(|l| l.mode); - if let (true, Some(mode)) = (flow, mode) { - // Into (or within) an auto layout frame: the layers take the slot (a grid: the - // cell) under the pointer, as the frame shows it now. A frame other than the - // one they come from is outlined too. - let cell = if mode == LayoutMode::Grid { crate::drop::grid_cell_at(doc, target, p) } else { None }; - let idx = - if mode == LayoutMode::Grid { 0 } else { crate::drop::auto_layout_slot(doc, target, p, &moving).map_or(0, |(i, _)| i) }; - for m in &moving { - if doc.get(*m)?.parent != Some(target) { - doc.reparent(*m, target, None)?; - } - } - if let (Some(first), Some((col, row, rect))) = (moving.first().copied(), cell) { - let cl = &mut doc.get_mut(first)?.child_layout; - cl.grid_column = Some(col); - cl.grid_row = Some(row); - drop_cell = Some(rect); - } else if mode != LayoutMode::Grid { - let mut kids: Vec = doc.get(target)?.children.iter().copied().filter(|c| !moving.contains(c)).collect(); - for (k, m) in moving.iter().enumerate() { - kids.insert((idx + k).min(kids.len()), *m); - } - let gap = doc.node(target).and_then(|n| n.layout.as_ref()).map_or(0.0, |l| l.gap); - doc.get_mut(target)?.children = kids; - slot_axis = Some((mode == LayoutMode::Vertical, gap)); - } - drop_target = (Some(target) != home).then_some(target); - in_flow = true; - return Ok(()); - } - // A free move. Layers dragged out of an auto layout flow leave it (onto the page - // while dragging; the drop puts them in the frame under the pointer). - for m in &moving { - let in_al = doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); - if flow && in_al { - doc.reparent(*m, page, None)?; - } - move_world(doc, *m, d.x, d.y)?; - } - drop_target = (flow && target != page).then_some(target); - Ok(()) - })?; - 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). - 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)) - }); - self.tool.overlay.drop_cell = drop_cell; - self.tool.overlay.drop_target = drop_target; - self.tool.overlay.cursor = if self.refused(&placed, true) { - Cursor::NotAllowed - } else if dup { - Cursor::Copy - } else { - Cursor::Default - }; - self.tool.gesture = g.clone(); - if let Gesture::Move { moved, .. } = &mut self.tool.gesture { - *moved = true; - } - return Ok(()); - } - Gesture::Resize { start, r, scale_tool, .. } => { - let res = self.resize_drag(ev, *start, r, *scale_tool); - self.tool.gesture = Gesture::Resize { start: *start, r: r.clone(), scale_tool: *scale_tool, moved: true }; - if self.refused(&self.selection(), false) { - self.tool.overlay.cursor = Cursor::NotAllowed; - } - return res; - } - Gesture::Rotate { center, start_angle, ids } => { - let mut a = ((ev.p() - *center).atan2() - start_angle).to_degrees(); - if ev.shift { - a = (a / 15.0).round() * 15.0; - } - let (c, ids) = (*center, ids.clone()); - self.preview(|doc, _| { - for i in &ids { - crate::cmd::arrange::rotate_world(doc, *i, c, -a)?; - } - Ok(()) - })?; - // The cursor turns with the pointer about the centre. - self.tool.overlay.cursor = - if self.refused(&ids, false) { Cursor::NotAllowed } else { Cursor::Rotate((ev.p() - c).atan2().to_degrees()) }; - Ok(()) - } - Gesture::Draw { start, id, parent, targets } => { - let (start, id, parent, targets) = (*start, *id, *parent, targets.clone()); - self.tool.gesture = g; - return self.draw_drag(ev, start, id, parent, &targets); - } - Gesture::TextBox { id, start, line } => { - let (id, start, line) = (*id, *start, *line); - self.tool.gesture = g; - return self.text_box_drag(ev, id, start, line); - } - Gesture::Arc { id, handle, arc } => { - let (id, handle, arc) = (*id, *handle, *arc); - self.tool.gesture = g; - return self.arc_drag(ev, id, handle, arc); - } - Gesture::Shape { id, handle } => { - let (id, handle) = (*id, *handle); - let doc = self.doc()?.clone(); - if let Some(props) = crate::shape_handles::drag(&doc, id, handle, ev.p(), ev.alt) { - self.preview(|doc, _| crate::cmd::props::apply("node.set", doc, id, &props))?; - } - self.tool.overlay.shape = shape_hint(self.doc()?, id, handle, ev.zoom); - self.tool.overlay.shape_hover = Some(handle); - Ok(()) - } - Gesture::Pan { sx, sy, cx, cy } => { - let ds = self.ds_mut()?; - let z = ds.view.zoom.max(1e-6); - ds.view.cx = cx - (ev.sx - sx) / z; - ds.view.cy = cy - (ev.sy - sy) / z; - ds.revision += 1; - Ok(()) - } - Gesture::Pencil { points, pressure } => { - let (mut pts, mut pressure) = (points.clone(), pressure.clone()); - if pts.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { - pts.push(ev.p()); - pressure.push(ev.pressure); - } - self.tool.overlay.pen = pts.iter().map(|p| [p.x, p.y]).collect(); - self.tool.gesture = Gesture::Pencil { points: pts, pressure }; - if let Ok(ds) = self.ds_mut() { - ds.revision += 1; - } - return Ok(()); - } - Gesture::TextSelect { id } => { - let id = *id; - let idx = self.text_index_at(id, ev.p())?; - textedit::set_caret(self, idx, true)?; - Ok(()) - } - Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()), - Gesture::Connect { from, start } => { - let (from, start) = (*from, *start); - self.connect_drag(ev, from, start); - return Ok(()); - } - Gesture::Erase { gone } => { - self.tool.gesture = Gesture::Erase { gone: gone.clone() }; - return self.erase_at(ev); - } - Gesture::Reattach { id, from, start } => { - let (id, from, start) = (*id, *from, *start); - self.reattach_drag(ev, id, from, start); - return Ok(()); - } - Gesture::PenDrag { anchor } => { - // A click's wobble pulls out no handle. - let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor }; - if let Some(pen) = self.tool.pen.as_mut() - && let Some(last) = pen.points.last_mut() - { - last.2 = out; - last.1 = -out; - } - self.pen_update()?; - Ok(()) - } - }; - self.tool.gesture = g; - res - } - - /// An arc handle drag: the new arc, written through `node.set`'s keys. - fn arc_drag(&mut self, ev: &PointerEvent, id: NodeId, handle: ArcHandle, arc: crate::arc_handles::Arc) -> Result<()> { - let doc = self.doc()?.clone(); - let Some(next) = crate::arc_handles::drag(&doc, id, handle, arc, ev.p(), ev.shift, ev.zoom) else { return Ok(()) }; - let props = crate::arc_handles::props(next); - self.preview(|doc, _| crate::cmd::props::apply("node.set", doc, id, &props))?; - self.tool.gesture = Gesture::Arc { id, handle, arc: next }; - let at = crate::arc_handles::handle_at(self.doc()?, id, handle, next, ev.zoom); - self.tool.overlay.arc = at.map(|at| ArcHint { handle, label: next.label(handle), at }); - self.tool.overlay.arc_hover = Some(handle); - Ok(()) - } - - fn resize_drag(&mut self, ev: &PointerEvent, start: Point, r: &ResizeStart, scale_tool: bool) -> Result<()> { - let page = self.page()?; - let snap = self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; - let ids: Vec = r.nodes.iter().map(|n| n.0).collect(); - let targets = if snap { Some(Targets::gather(self.doc()?, page, &ids)) } else { None }; - let mut p = ev.p(); - let mut guides = Vec::new(); - if let Some(t) = &targets { - let (dx, dy, g) = t.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); - p.x += dx; - p.y += dy; - guides = g; - } - let _ = start; - // In the handle box's own axes (one layer: its local space). - let (frame, w0, h0) = r.frame; - // The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool - // scales by the factor as dragged). - if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) { - p = Point::new(p.x.round(), p.y.round()); - } - let q = frame.inverse() * p; - let is_line = r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false); - if scale_tool { - 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 { - Handle::Corner(0) => (p, frame * Point::new(w0, 0.0)), - _ => (frame * Point::new(0.0, 0.0), p), - }; - let id = ids.first().copied().unwrap_or(NodeId(0)); - self.preview(|doc, _| { - let mut v = b - a; - if ev.shift { - let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; - v = Vec2::from_angle(ang) * v.hypot(); - } - let len = v.hypot(); - let world = Affine::translate(a.to_vec2()) * Affine::rotate(v.atan2()); - doc.set_world_transform(id, world)?; - figcraft_layout::set_size(doc, id, len, 0.0); - Ok(()) - })?; - self.tool.overlay.guides = guides; - 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); - 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)); - let (nw, nh) = ((x1 - x0).abs(), (y1 - y0).abs()); - let nodes = r.nodes.clone(); - let single = r.single; - let handle = r.handle; - // What's in a section stays where it is on the page, and so does what's in a frame resized - // with Cmd/Ctrl held (its constraints are ignored). - let keeps = |doc: &Document, id: NodeId| { - doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }) || (ev.cmd && single && n.kind.is_frame_like())) - }; - self.preview(|doc, _| { - if single { - let Some((id, world, _, _)) = nodes.first() else { return Ok(()) }; - let kept = if keeps(doc, *id) { crate::cmd::props::children_worlds(doc, *id) } else { Vec::new() }; - let mut t = *world * Affine::translate((nx0, ny0)); - if flip_x { - t *= Affine::new([-1.0, 0.0, 0.0, 1.0, nw, 0.0]); - } - if flip_y { - t *= Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, nh]); - } - // Text reflow and auto layout can recompute this box after the preview (a size bound - // to a variable lets go of it: the resize is an explicit edit). - let n = doc.get(*id)?; - let crop_images = ev.cmd - && !matches!(n.kind, NodeKind::Text(_)) - && !std::iter::once(*id).chain(doc.ancestors(*id)).any(|a| doc.node(a).is_some_and(|n| n.layout.is_some())); - if crop_images { - // Crop the layer's image fills without moving their pixels on the page. - // Work from the interaction snapshot, just like the geometry on every move. - let (w, h) = (n.width.max(1e-6), n.height.max(1e-6)); - let to_old = - Affine::scale_non_uniform(1.0 / w, 1.0 / h) * world.inverse() * t * Affine::scale_non_uniform(nw.max(0.01), nh.max(0.01)); - let mut fills = n.fills.clone(); - for paint in &mut fills { - let PaintKind::Image(img) = &mut paint.kind else { continue }; - // A repeating image cannot be represented by a single Crop placement. - if img.scale_mode == ImageScaleMode::Tile { - continue; - } - let Some(data) = doc.images.get(&img.image) else { continue }; - let (iw, ih) = (f64::from(data.width), f64::from(data.height)); - let crop = if img.scale_mode == ImageScaleMode::Crop { - img.effective_crop(iw, ih, w, h) - } else { - let (rw, rh) = img.rotated_size(iw.max(1.0), ih.max(1.0)); - let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) }; - [w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)] - }; - // A degenerate (zero-scale) layer has no inverse: its image keeps its placement. - let crop = (Affine::new(crop) * to_old).as_coeffs(); - if !crop.iter().all(|c| c.is_finite()) { - continue; - } - img.crop = Some(crop); - img.scale_mode = ImageScaleMode::Crop; - } - doc.get_mut(*id)?.fills = fills; - } - doc.set_world_transform(*id, t)?; - if kept.is_empty() { - crate::cmd::props::resize_node(doc, *id, nw.max(0.01), nh.max(0.01))?; - } else { - figcraft_layout::set_size(doc, *id, nw.max(0.01), nh.max(0.01)); - crate::cmd::props::restore_worlds(doc, &kept)?; - } - 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 { - match handle { - Handle::Edge(1) | Handle::Edge(3) if t.auto_resize == TextAutoResize::Truncate => {} - Handle::Edge(1) | Handle::Edge(3) => t.auto_resize = TextAutoResize::Height, - _ => t.fix_size(true, true), - } - } - return Ok(()); - } - // Several layers: map the old box onto the new one, in the box's axes. Each layer's - // centre follows the box (mirrored when it flips); each keeps its rotation and scales - // along its own axes (see `axis_factors`), turning over with the box. - let (sx, sy) = (if w0 > 1e-9 { (x1 - x0) / w0 } else { 1.0 }, if h0 > 1e-9 { (y1 - y0) / h0 } else { 1.0 }); - let mirror = Affine::new([sx.signum(), 0.0, 0.0, sy.signum(), 0.0, 0.0]); - let inv = frame.inverse(); - for (id, world, w, h) in &nodes { - let local = inv * *world; - let center = local * Point::new(w / 2.0, h / 2.0); - let (kw, kh) = figcraft_layout::axis_factors(local, sx, sy); - let (w2, h2) = (w * kw, h * kh); - let lin = (mirror * local).with_translation(Vec2::ZERO); - let origin = Point::new(x0 + center.x * sx, y0 + center.y * sy) - lin * Point::new(w2 / 2.0, h2 / 2.0); - let kept = if keeps(doc, *id) { crate::cmd::props::children_worlds(doc, *id) } else { Vec::new() }; - doc.set_world_transform(*id, frame * lin.with_translation(origin))?; - crate::cmd::props::resize_node(doc, *id, w2, h2)?; - crate::cmd::props::restore_worlds(doc, &kept)?; - } - Ok(()) - })?; - self.tool.overlay.guides = guides; - 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(); - let page = ds.page; - if ds.text_edit.is_some() { - textedit::end(self)?; - } - // Frames drawn inside frames nest; sections only at the top or in sections. - let parent = match self.tool.tool { - ToolId::Section => { - frame_at(&doc, page, ev.p(), &[]).filter(|f| doc.node(*f).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))).unwrap_or(page) - } - _ => frame_at(&doc, page, ev.p(), &[]).unwrap_or(page), - }; - let targets = Targets::gather(&doc, page, &[]); - self.begin_interaction(self.tool.tool.label())?; - self.tool.gesture = Gesture::Draw { start: ev.p(), id: None, parent, targets }; - Ok(()) - } - - fn draw_drag(&mut self, ev: &PointerEvent, start: Point, id: Option, parent: NodeId, targets: &Targets) -> Result<()> { - let tool = self.tool.tool; - let mut p = ev.p(); - let mut guides = Vec::new(); - if self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl { - let (dx, dy, g) = targets.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); - p.x += dx; - p.y += dy; - guides = g; - } - let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true); - let mut created = id; - let page = self.page()?; - self.preview(|doc, sel| { - let (mut a, mut b) = (start, p); - if pixel { - a = Point::new(a.x.round(), a.y.round()); - b = Point::new(b.x.round(), b.y.round()); - } - let pw = doc.world_transform(parent); - let (kind, base) = match tool { - ToolId::Frame => (NodeKind::Frame, "Frame"), - ToolId::Section => (NodeKind::Section { contents_hidden: false }, "Section"), - ToolId::Slice => (NodeKind::Slice, "Slice"), - ToolId::Rectangle => (NodeKind::Rectangle, "Rectangle"), - ToolId::Ellipse => (NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Ellipse"), - ToolId::Polygon => (NodeKind::Polygon { count: 3 }, "Polygon"), - ToolId::Star => (NodeKind::Star { count: 5, inner_ratio: 0.38 }, "Star"), - ToolId::Line => (NodeKind::Line, "Line"), - ToolId::Arrow => (NodeKind::Line, "Arrow"), - _ => return Ok(()), - }; - let name = match tool { - ToolId::Frame | ToolId::Section | ToolId::Slice => doc.next_name(base), - _ => base.to_string(), - }; - let nid = doc.alloc_id(); - let mut n = if tool == ToolId::Section { Node::new_section(nid, name, 0.0, 0.0) } else { Node::new(nid, kind, name, 0.0, 0.0) }; - if tool == ToolId::Arrow { - n.stroke.cap_end = StrokeCap::LineArrow; - } - if matches!(tool, ToolId::Line | ToolId::Arrow) { - let mut v = b - a; - if ev.shift { - let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; - v = Vec2::from_angle(ang) * v.hypot(); - } - // Alt draws from the centre, as for the other shapes. - let (a, v) = if ev.alt { (a - v, v * 2.0) } else { (a, v) }; - n.width = v.hypot(); - n.transform = pw.inverse() * Affine::translate(a.to_vec2()) * Affine::rotate(v.atan2()); - } else { - let mut d = b - a; - if ev.shift { - let m = d.x.abs().max(d.y.abs()); - d = Vec2::new(m * d.x.signum(), m * d.y.signum()); - } - let (x0, y0, x1, y1) = if ev.alt { (a.x - d.x, a.y - d.y, a.x + d.x, a.y + d.y) } else { (a.x, a.y, a.x + d.x, a.y + d.y) }; - let r = Rect::new(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); - n.width = r.width(); - n.height = r.height(); - n.transform = pw.inverse() * Affine::translate((r.x0, r.y0)); - } - doc.insert(n, parent, None)?; - *sel = vec![nid]; - created = Some(nid); - let _ = page; - Ok(()) - })?; - self.tool.overlay.guides = guides; - self.tool.gesture = Gesture::Draw { start, id: created, parent, targets: targets.clone() }; - Ok(()) - } - - fn up(&mut self, ev: &PointerEvent) -> Result<()> { - 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.arc = None; - self.tool.overlay.shape = 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(); - match g { - Gesture::Move { ids, moved, duplicate, start, deselect, .. } => { - if moved { - // A free move lands in the frame under the pointer (or the page) where it was - // dropped; layers in an auto layout frame already sit in their slot. - let page = self.page()?; - let sel = self.selection(); - let sources = if duplicate { ids.clone() } else { Vec::new() }; - let moving = if duplicate { sel } else { ids }; - let doc = self.doc()?.clone(); - let in_al = - moving.iter().any(|m| doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); - let target = drop_target.unwrap_or(page); - let current = moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent); - if !in_al && current != Some(target) { - let ds = self.ds_mut()?; - let before = ds.doc.clone(); - let d = ds.doc_mut(); - for m in &moving { - // Group, boolean group and instance layers stay put: they only move. - let from = d.node(*m).and_then(|n| n.parent); - if from.is_some_and(|f| crate::drop::keeps_layers(d, f)) || crate::components::structure_locked(d, target) { - continue; - } - // A layer can't go into itself; it stays where it is. - let _ = d.reparent(*m, target, None); - } - crate::normalize(ds.doc_mut(), &before); - } - // Reordering or dragging layers out of an instance (outside its slots), or - // moving layers inside one, are edits only its main can make: the gesture is - // undone. - if self.refused(&moving, true) { - self.cancel_interaction()?; - } else { - self.commit_interaction()?; - // Duplicate repeats a free Alt-drag's step (an auto layout places its own). - if duplicate && !in_al && moving.len() == sources.len() { - self.ds_mut()?.last_duplicate = Some(crate::cmd::edit::Duplicated { sources, copies: moving }); - } - } - } else { - self.cancel_interaction()?; - if let Some(d) = deselect { - let sel = self.selection().into_iter().filter(|x| *x != d).collect(); - self.set_selection(sel); - } - if deselect.is_none() && !(ev.shift || ev.cmd || duplicate) { - self.widget_click(start, ev.tol() * 2.0)?; - } - } - } - Gesture::Resize { .. } | Gesture::Rotate { .. } | Gesture::Arc { .. } | Gesture::Shape { .. } => { - // Layers inside an instance keep their main's size and rotation. - if self.refused(&self.selection(), false) { - self.cancel_interaction()?; - } else { - self.commit_interaction()?; - } - } - Gesture::Draw { start, id, parent, .. } => { - let tiny = (ev.p() - start).hypot() * ev.zoom < 3.0; - if tiny || id.is_none() { - // Click without drag: Figma makes a 100×100 shape (frames 100×100 too). - self.cancel_interaction()?; - self.begin_interaction(self.tool.tool.label())?; - let (w, h) = self.tool.tool.click_size(); - let target = start + Vec2::new(w, h); - let mut fake = ev.clone(); - fake.x = target.x; - fake.y = target.y; - fake.shift = false; - fake.alt = false; - self.tool.gesture = Gesture::None; - let targets = Targets::default(); - self.draw_drag(&fake, start, None, parent, &targets)?; - self.tool.gesture = Gesture::None; - } - if matches!(self.tool.tool, ToolId::Frame | ToolId::Section) - && let Some(drawn) = self.selection().first().copied() - { - let ds = self.ds_mut()?; - let before = ds.doc.clone(); - take_enclosed(ds.doc_mut(), drawn)?; - crate::normalize(ds.doc_mut(), &before); - } - self.commit_interaction()?; - if !self.prefs.keep_tool { - self.set_tool(ToolId::Move); - } - } - Gesture::Pan { .. } => { - self.tool.overlay.cursor = Cursor::Grab; - if let Some(t) = self.tool.temp_from.take() { - self.tool.tool = t; - } - } - Gesture::Pencil { mut points, mut pressure } => { - self.tool.overlay.pen.clear(); - // The stroke ends where the button came up, even without a move to get there. - if points.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { - points.push(ev.p()); - pressure.push(ev.pressure); - } - self.pencil_finish(&points, &pressure)?; - } - Gesture::Connect { from, start } => self.connect_up(ev, from, start)?, - Gesture::Erase { .. } => self.erase_up()?, - Gesture::Reattach { id, from, start } => self.reattach_up(ev, id, from, start)?, - Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {} - Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {} - Gesture::Vector(_) => self.commit_interaction()?, - } - if let Ok(ds) = self.ds_mut() { - ds.revision += 1; - } - Ok(()) - } - - /// Handle a double-click gesture: finish a pen path, rename or click labels/widgets, - /// cancel an in-progress move/resize/draw/marquee, handle board double-clicks, enter text - /// editing (select-all on entry, word-select inside the running edit), enter vector editing - /// for selected shapes, and step groups/frames one level deeper. - /// A double-click on `leaf` that a board object or a widget's editable field takes (typing into - /// a sticky or a shape on a board; editing the field like any text, its plugin told when editing - /// ends). Whether it was taken. - fn object_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId], p: Point, edits: bool) -> Result { - if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && self.board_double_click(doc, leaf, sel)? { - return Ok(true); - } - match crate::widgets::field_at(doc, leaf, p).filter(|_| edits) { - Some(field) => textedit::begin(self, field, None, false).map(|()| true), - None => Ok(false), - } - } - - fn double_click(&mut self, ev: &PointerEvent) -> Result<()> { - let ds = self.ds()?; - let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); - if self.tool.tool == ToolId::Pen { - return self.pen_finish(); - } - let edits = !self.tool.dev_mode; - // A frame's or section's name label: rename it there (the canvas puts a field over it). - // Labels come before widgets, as on a press; the second press began a move: undo it. - if edits - && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) - && !(ev.shift || ev.cmd || ev.alt) - && let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p()) - { - self.tool.gesture = Gesture::None; - self.cancel_interaction()?; - if sel != [id] { - self.set_selection(vec![id]); - } - self.request_ui("canvas.renameLabel", serde_json::json!({"id": id})); - return Ok(()); - } - // Quick clicks on a widget's clickable part are clicks, one by one. - if edits - && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) - && !(ev.shift || ev.cmd || ev.alt) - && self.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|hit| crate::widgets::target_at(&doc, hit, ev.p())).is_some() - { - self.tool.gesture = Gesture::None; - self.cancel_interaction()?; - self.widget_click(ev.p(), ev.tol() * 2.0)?; - return Ok(()); - } - // The second press began a move, a resize, a shape or a marquee: a double-click isn't one. - if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. } | Gesture::Draw { .. } | Gesture::Marquee { .. }) { - self.tool.gesture = Gesture::None; - self.tool.overlay.marquee = None; - self.cancel_interaction()?; - } - let hit_here = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); - // A double-click anywhere on the one selected shape edits its points, like Enter: also - // inside a hollow (unfilled) closed shape, where the hit test finds nothing under the - // pointer (an open path or a line has no inside: only its stroke counts). - if edits - && let [id] = sel[..] - && doc.node(id).is_some_and(|n| n.kind.is_shape() && figcraft_doc::geometry::is_closed(n)) - && doc.world_bounds(id).is_some_and(|b| b.contains(ev.p())) - && hit_here.is_none_or(|h| h == id || doc.is_ancestor(id, h)) - { - return crate::vectoredit::begin_many(self, &[id]); - } - let Some(leaf) = hit_here else { return Ok(()) }; - if self.object_double_click(&doc, leaf, &sel, ev.p(), edits)? { - return Ok(()); - } - let target = select_target(&doc, leaf, &sel, true); - // Text: edit at the click. - if edits - && doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) - && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) - { - // Entering a text selects all of it; in the text being edited, the word. A - // double-click inside the running edit is an ordinary edit: starting a new session - // here would replace the IME owner's epoch and drop a composition on a plain gesture. - let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| te.id == target); - let idx = self.text_index_at(target, ev.p())?; - if editing { - textedit::select_word(self, idx)?; - } else { - textedit::begin(self, target, Some(idx), false)?; - textedit::key(self, "SelectAll", false, false, false)?; - } - return Ok(()); - } - // Shapes and vectors: enter vector edit mode (every selected shape, the clicked one active). - // A shape clicked on its own edits even when the click that selects it and the double-click - // arrived in one frame (a press and a release the canvas took together), so no selection was - // made yet; another layer's shapes are reached one level at a time, as below. - if edits && doc.node(target).is_some_and(|n| n.kind.is_shape()) && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) - { - let mut ids = if sel.contains(&target) { crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default() } else { vec![target] }; - ids.retain(|i| *i != target); - ids.insert(0, target); - return crate::vectoredit::begin_many(self, &ids); - } - // Groups, frames and boolean groups: select one level deeper (the layer under the pointer). - let deeper = figcraft_doc::geometry::enter_target(&doc, leaf, &sel); - if deeper != target || !sel.contains(&deeper) { - self.set_selection(vec![deeper]); - } - Ok(()) - } - - /// A plain click at `p` on a clickable part of a widget calls its plugin back (a failure is - /// shown as a message). - fn widget_click(&mut self, p: Point, tol: f64) -> Result<()> { - let (doc, page) = (self.doc()?.clone(), self.page()?); - let Some((id, target)) = self.hit(&doc, page, p, tol).and_then(|hit| crate::widgets::target_at(&doc, hit, p)) else { return Ok(()) }; - if let Err(e) = self.execute("widget.click", &serde_json::json!({"id": id, "target": target})) { - self.message = Some(e.to_string()); - } - Ok(()) - } - - /// The char index in text node `id` at page point `p`. - pub fn text_index_at(&self, id: NodeId, p: Point) -> Result { - let doc = self.doc()?; - let n = doc.get(id)?; - let local = doc.world_transform(id).inverse() * p; - let Some((layout, dy)) = figcraft_text::layout_node(n) else { return Ok(0) }; - Ok(layout.hit(local.x, local.y - dy)) - } - - fn text_click(&mut self, id: NodeId, ev: &PointerEvent, extend: bool) -> Result<()> { - let idx = self.text_index_at(id, ev.p())?; - textedit::set_caret(self, idx, extend)?; - self.tool.gesture = Gesture::TextSelect { id }; - Ok(()) - } - - fn text_down(&mut self, ev: &PointerEvent) -> Result<()> { - let ds = self.ds()?; - let (doc, page) = (ds.doc.clone(), ds.page); - if let Some(te) = ds.text_edit.clone() { - if doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { - return self.text_click(te.id, ev, ev.shift); - } - textedit::end(self)?; - } - // Clicking existing text edits it. - if let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) - && doc.node(leaf).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) - { - let idx = self.text_index_at(leaf, ev.p())?; - textedit::begin(self, leaf, Some(idx), false)?; - self.tool.gesture = Gesture::TextSelect { id: leaf }; - return Ok(()); - } - // New text at the click (Figma puts the baseline-ish top-left there). - let parent = frame_at(&doc, page, ev.p(), &[]).unwrap_or(page); - let ds = self.ds_mut()?; - let d = ds.doc_mut(); - let id = d.alloc_id(); - let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", 1.0, 14.0); - let pw = d.world_transform(parent); - let at = Point::new(ev.x.round(), (ev.y - 7.0).round()); - n.transform = pw.inverse() * Affine::translate(at.to_vec2()); - n.name = String::new(); - if let Some(fills) = readable_text_fills(d, parent) { - n.fills = fills; - } - if let Some((w, h)) = figcraft_text::auto_size(&n) { - n.width = w.max(1.0); - n.height = h; - } - let line = n.height; - d.insert(n, parent, None)?; - ds.selection = vec![id]; - textedit::begin(self, id, Some(0), true)?; - self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line }; - if !self.prefs.keep_tool { - self.tool.tool = ToolId::Move; - } - Ok(()) - } - - /// The layer whose path is under `ev` for the Text on a path tool and where along it the press - /// is (0..1 by length): the front-most shape or vector layer within a few screen pixels of its - /// first subpath (the one text follows), not locked and not inside an instance. Layers in front - /// whose path is further away don't hide it. - pub(crate) fn path_at(&self, doc: &Document, page: NodeId, ev: &PointerEvent) -> Option<(NodeId, f64)> { - let reach = ev.tol() * PATH_REACH_PX; - let near = |id: NodeId| -> Option<(NodeId, f64)> { - let n = doc.node(id)?; - let shape = matches!( - n.kind, - NodeKind::Rectangle - | NodeKind::Ellipse { .. } - | NodeKind::Polygon { .. } - | NodeKind::Star { .. } - | NodeKind::Line - | NodeKind::Vector { .. } - ); - if !shape || locked_in(doc, id) || crate::components::inside_instance(doc, id) { - return None; - } - let geom = figcraft_doc::geometry::stroke_geometry(n)?; - let line = figcraft_geom::measure::polylines(&geom)?.into_iter().next()?; - // Measured on the page (a layer scaled more one way than the other is still reached - // where it shows), the start by length along the layer's own path. - let world = doc.world_transform(id); - let p = ev.p(); - let mut best: Option<(f64, f64)> = None; - for (i, w) in line.points.windows(2).enumerate() { - let (a, b) = (world * w[0], world * w[1]); - let d = b - a; - let l2 = d.hypot2(); - let u = if l2 > 1e-18 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 }; - let dist = (a.lerp(b, u) - p).hypot(); - if best.is_none_or(|(_, bd)| dist < bd) { - let (la, lb) = (*line.lengths.get(i)?, *line.lengths.get(i + 1)?); - best = Some((la + u * (lb - la), dist)); - } - } - let (along, dist) = best?; - let len = line.length(); - (len > 1e-9 && dist <= reach).then_some((id, along / len)) - }; - let mut candidates = Vec::new(); - path_candidates(doc, page, ev.p(), reach, 0, &mut candidates); - candidates.into_iter().find_map(near) - } - - /// A press with the Text on a path tool: on a path, a new empty text layer goes onto it, - /// starting where it was pressed, and typing starts. The text takes the path layer's place, - /// geometry, transform, constraints and effects, and its fills when one of them shows; the path - /// layer is removed. Ending the edit with nothing typed puts the path back without an undo step - /// ([`textedit::end`]). Elsewhere a press does nothing. - fn text_on_path_down(&mut self, ev: &PointerEvent) -> Result<()> { - if let Some(te) = self.ds()?.text_edit.clone() { - if self.ds()?.doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { - return self.text_click(te.id, ev, ev.shift); - } - textedit::end(self)?; - } - let ds = self.ds()?; - let (doc, page) = (ds.doc.clone(), ds.page); - let Some((path, start)) = self.path_at(&doc, page, ev) else { return Ok(()) }; - let Some(pn) = doc.node(path) else { return Ok(()) }; - let (Some(parent), Some(geom)) = (pn.parent, figcraft_doc::geometry::stroke_geometry(pn)) else { return Ok(()) }; - let index = doc.index_in_parent(path); - self.begin_interaction("Text on a path")?; - let ds = self.ds_mut()?; - let d = ds.doc_mut(); - let id = d.alloc_id(); - let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", pn.width, pn.height); - n.name = String::new(); - n.transform = pn.transform; - n.constraints = pn.constraints; - n.child_layout = pn.child_layout.clone(); - let effects = !pn.effects.is_empty(); - if effects { - n.effects = pn.effects.clone(); - n.styles.effect = pn.styles.effect.clone(); - } - // A path drawn with a stroke only has no fill to give: the text gets the usual one. - let filled = pn.fills.iter().any(|f| f.visible); - if filled { - n.fills = pn.fills.clone(); - n.styles.fill = pn.styles.fill.clone(); - } else if let Some(fills) = readable_text_fills(d, parent) { - n.fills = fills; - } - // As `text.onPath`: a binding goes with the list it binds into (`fill`, `fills`, `fills/N/…`). - let carried = |k: &str| match crate::cmd::lists::list_binding(k) { - Some((crate::cmd::lists::BoundList::Fills, ..)) => filled, - Some((crate::cmd::lists::BoundList::Effects, ..)) => effects, - _ => false, - }; - n.bound = pn.bound.iter().filter(|(k, _)| carried(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); - if let NodeKind::Text(t) = &mut n.kind { - let mut tp = TextPath::new(&geom); - tp.start = start; - t.path = Some(Box::new(tp)); - } - d.insert(n, parent, index)?; - d.remove(path)?; - ds.selection = vec![id]; - textedit::begin(self, id, Some(0), true)?; - if !self.prefs.keep_tool { - self.tool.tool = ToolId::Move; - } - Ok(()) - } - - /// Drag out a new text box: past the drag slop it has the drag's size (at least one line - /// tall) and its top-left, and that size is fixed. - fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> { - if within_slop(ev, start) { - return Ok(()); - } - let r = Rect::from_points(start, ev.p()); - let d = self.ds_mut()?.doc_mut(); - let pw = d.node(id).and_then(|n| n.parent).map(|p| d.world_transform(p)).unwrap_or_default(); - let n = d.get_mut(id)?; - n.transform = pw.inverse() * Affine::translate((r.x0.round(), r.y0.round())); - n.width = r.width().round().max(1.0); - n.height = r.height().round().max(line); - n.child_layout.sizing_h = Sizing::Fixed; - n.child_layout.sizing_v = Sizing::Fixed; - if let NodeKind::Text(t) = &mut n.kind { - t.auto_resize = TextAutoResize::None; - } - self.ds_mut()?.revision += 1; - Ok(()) - } - - fn eyedrop(&mut self, ev: &PointerEvent) -> Result<()> { - let ds = self.ds()?; - let doc = ds.doc.clone(); - let view = figcraft_render::View { origin: (ev.x, ev.y), scale: ev.zoom.max(0.01), width: 1, height: 1 }; - let Some(pix) = figcraft_render::render_view(&doc, ds.page, &view, &Default::default()) else { return Ok(()) }; - let rgba = figcraft_render::to_rgba8(&pix); - let c = figcraft_color::Color::from_rgba8( - rgba.first().copied().unwrap_or(0), - rgba.get(1).copied().unwrap_or(0), - rgba.get(2).copied().unwrap_or(0), - 255, - ); - let sel = ds.selection.clone(); - if self.tool.pick_only { - self.tool.picked = Some(c); - } else if !sel.is_empty() { - self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?; - } - self.recent_colors.retain(|x| *x != c); - self.recent_colors.insert(0, c); - self.recent_colors.truncate(16); - self.message = Some(format!("Picked #{}", c.to_hex())); - self.set_tool(ToolId::Move); - Ok(()) - } - - /// The pen tool's snapped position for `ev`: its path's vector (or, before it has one, its first - /// point) counts among the targets. - fn snap_pen_tool(&mut self, ev: &PointerEvent) -> Result<(Point, Vec)> { - let (id, extra) = match &self.tool.pen { - Some(pen) if pen.id.is_some() => (pen.id, Vec::new()), - Some(pen) => (None, pen.points.iter().map(|(p, _, _)| *p).collect()), - None => (None, Vec::new()), - }; - self.snap_pen(ev, id, &extra) - } - - fn pen_down(&mut self, ev: &PointerEvent) -> Result<()> { - self.tool.overlay.guides.clear(); - // The segment being placed shows as itself while the press lasts. - self.tool.overlay.rubber = None; - self.tool.overlay.rubber_handle = None; - let p = if ev.shift { ev.p() } else { self.snap_pen_tool(ev)?.0 }; - let (doc, page) = { - let ds = self.ds()?; - (ds.doc.clone(), ds.page) - }; - // With an open path selected, a click on one of its end points continues it in edit mode. - if self.tool.pen.is_none() - && let Some((id, v)) = selected_end_at(&doc, &self.selection(), ev.p(), 8.0 / ev.zoom.max(1e-6)) - { - return self.pen_edit(id, Some(v)); - } - let pen = self.tool.pen.get_or_insert_with(PenPath::default); - // Clicking the first anchor closes the path. - if pen.points.len() > 2 - && let Some((first, _, _)) = pen.points.first() - && (*first - p).hypot() * ev.zoom < 8.0 - { - pen.closed = true; - self.pen_update()?; - return self.pen_finish(); - } - // Clicking another of its own anchors (not the last) connects there and ends the path. - let near = |q: &Point| (*q - p).hypot() * ev.zoom < 8.0; - if let Some(k) = (1..pen.points.len().saturating_sub(1)).find(|k| pen.points.get(*k).is_some_and(|(q, _, _)| near(q))) { - pen.join = Some(k); - self.pen_update()?; - return self.pen_finish(); - } - if pen.parent.is_none() { - pen.parent = Some(frame_at(&doc, page, p, &[]).unwrap_or(page)); - } - let mut p = p; - if ev.shift - && let Some((last, _, _)) = pen.points.last() - { - p = at_45_degrees(*last, p); - } - pen.points.push((p, Vec2::ZERO, Vec2::ZERO)); - self.tool.gesture = Gesture::PenDrag { anchor: p }; - self.pen_update() - } - - /// Rebuild the pen's vector node from its anchors. - fn pen_update(&mut self) -> Result<()> { - let Some(pen) = self.tool.pen.clone() else { return Ok(()) }; - self.tool.overlay.pen = pen.points.iter().map(|(p, _, _)| [p.x, p.y]).collect(); - self.tool.overlay.pen_handles = pen_handles(&pen.points); - if pen.points.len() < 2 { - if let Ok(ds) = self.ds_mut() { - ds.revision += 1; - } - return Ok(()); - } - let page = self.page()?; - let parent = pen.parent.unwrap_or(page); - if pen.id.is_none() { - self.begin_interaction("Pen")?; - } - let ds = self.ds_mut()?; - let doc = ds.doc_mut(); - let pw = doc.world_transform(parent); - let inv = pw.inverse(); - // Anchors and handles into the parent's space. - let pts: Vec<(Point, Point, Point)> = pen.points.iter().map(|(p, i, o)| (inv * *p, inv * (*p + *i), inv * (*p + *o))).collect(); - let mut path = figcraft_geom::BezPath::new(); - for (k, (p, _, _)) in pts.iter().enumerate() { - if k == 0 { - path.move_to(*p); - } else if let (Some(prev), Some(cur)) = (pts.get(k - 1), pts.get(k)) { - path.curve_to(prev.2, cur.1, *p); - } - } - if pen.closed - && let (Some(last), Some(first)) = (pts.last(), pts.first()) - { - path.curve_to(last.2, first.1, first.0); - path.close_path(); - } - use figcraft_geom::Shape; - let bb = path.bounding_box(); - path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); - let mut net = VectorNetwork::from_path(&path); - // Straight segments stay straight. - for s in &mut net.segments { - if (s.ta.0.abs() + s.ta.1.abs()) < 1e-9 && (s.tb.0.abs() + s.tb.1.abs()) < 1e-9 { - s.ta = (0.0, 0.0); - s.tb = (0.0, 0.0); - } - } - if !pen.closed { - net.regions.clear(); - } - // Anchor k is vertex k of the open path. - if let (Some(k), Some((last, _, out))) = (pen.join, pts.last()) { - let from = pts.len() - 1; - crate::vectoredit::ops::connect(&mut net, from, k, (out.x - last.x, out.y - last.y), (0.0, 0.0)); - } - let id = match pen.id { - Some(id) if doc.contains(id) => id, - _ => { - let id = doc.alloc_id(); - let mut n = Node::new(id, NodeKind::Vector { network: VectorNetwork::default() }, "Vector", 0.0, 0.0); - n.fills.clear(); - n.strokes = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]; - n.stroke.align = StrokeAlign::Center; - doc.insert(n, parent, None)?; - id - } - }; - let n = doc.get_mut(id)?; - n.kind = NodeKind::Vector { network: net }; - n.width = bb.width(); - n.height = bb.height(); - n.transform = Affine::translate((bb.x0, bb.y0)); - if pen.closed && n.fills.is_empty() { - n.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)]; - } - ds.selection = vec![id]; - ds.revision += 1; - if let Some(p) = self.tool.pen.as_mut() { - p.id = Some(id); - } - Ok(()) - } - - /// Edit `id` as a vector with the pen, going on from point `anchor` (or, without one, starting - /// a new path in the same layer at the next click). - fn pen_edit(&mut self, id: NodeId, anchor: Option) -> Result<()> { - self.set_tool(ToolId::Move); - crate::vectoredit::begin(self, id)?; - crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?; - crate::vectoredit::set_state(self, |e| { - e.anchor = anchor; - e.vertices = anchor.into_iter().collect(); - }) - } - - /// Enter/Escape while the pen draws: the path ends but its layer stays in edit mode with the - /// pen, so the next path joins the same vector (another Escape leaves edit mode). - pub fn pen_end(&mut self) -> Result<()> { - let id = self.tool.pen.as_ref().and_then(|p| p.id); - self.pen_finish()?; - match id { - Some(id) if self.doc()?.contains(id) => self.pen_edit(id, None), - _ => Ok(()), - } - } - - /// Finish the pen path (and keep the pen tool: double-click, Delete, a tool switch). - pub fn pen_finish(&mut self) -> Result<()> { - let pen = self.tool.pen.take(); - self.tool.overlay.pen.clear(); - self.tool.overlay.pen_handles.clear(); - self.tool.overlay.rubber = None; - self.tool.overlay.rubber_handle = None; - self.tool.gesture = Gesture::None; - if pen.as_ref().is_some_and(|p| p.id.is_some()) { - self.commit_interaction()?; - } - Ok(()) - } - - fn pencil_finish(&mut self, points: &[Point], pressure: &[Option]) -> Result<()> { - if points.len() < 2 { - return Ok(()); - } - // Smooth: fit a curve through the samples. - let mut poly = figcraft_geom::BezPath::new(); - for (k, p) in points.iter().enumerate() { - if k == 0 { - poly.move_to(*p); - } else { - poly.line_to(*p); - } - } - let fitted = figcraft_geom::kurbo::simplify::simplify_bezpath(poly.iter(), 0.75, &Default::default()); - let d = fitted.to_svg(); - if matches!(self.tool.tool, ToolId::Marker | ToolId::Highlighter) { - return self.ink_finish(&d); - } - let page = self.page()?; - let parent = frame_at(self.doc()?, page, points.first().copied().unwrap_or_default(), &[]).unwrap_or(page); - let pw = self.doc()?.world_transform(parent); - let mut local = fitted.clone(); - local.apply_affine(pw.inverse()); - let _ = d; - let (color, weight) = PENCIL_STROKE; - let mut props = json!({"fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"}); - // Pen pressure becomes the stroke's width profile (brushes follow it too). - if let Some(points) = pressure_profile(points, pressure) { - props["widthProfile"] = json!(points); - } - if let Some(b) = &self.tool.pencil_brush { - props["brush"] = serde_json::to_value(b).unwrap_or_default(); - } - self.execute( - "node.create", - &json!({"type": "vector", "path": local.to_svg(), "closed": false, "parent": parent, "name": "Vector", "props": props}), - )?; - Ok(()) - } - - /// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point - /// keys, Enter to finish the shape builder). Returns whether the key was used. - pub fn tool_key(&mut self, name: &str, shift: bool, alt: bool, cmd: bool) -> Result { - // Dev Mode edits nothing: keys go to the commands (which it refuses in the app). - if self.tool.dev_mode { - return Ok(false); - } - if self.ds().is_ok_and(|d| d.vector_edit.is_some()) { - return crate::vectoredit::key(self, name, shift, alt, cmd); - } - if !alt && !cmd && self.guide_key(name, shift)? { - return Ok(true); - } - crate::shapebuilder::key(self, name) - } - - /// Keys for a selected ruler guide: Delete removes it, arrows across it nudge it (Shift: the big - /// nudge), Escape deselects it. - fn guide_key(&mut self, name: &str, shift: bool) -> Result { - let Some((owner, index)) = self.ds()?.selected_guide() else { return Ok(false) }; - // Backspace is Delete, `Left` is `ArrowLeft`, and so on. - let name = crate::keymap::normalize(name).map_or(name, |(k, _)| k); - let page = self.page()?; - let at = |mut p: serde_json::Value| { - if owner != page { - p["frame"] = json!(owner); - } - p - }; - let Some(g) = self.doc()?.node(owner).and_then(|n| n.guides().get(index).cloned()) else { return Ok(false) }; - let step = if shift { self.prefs.nudge_big } else { self.prefs.nudge_small }; - let d = match (name, g.vertical) { - ("Delete", _) => { - self.execute("guide.remove", &at(json!({"index": index})))?; - return Ok(true); - } - ("Escape", _) => { - self.execute("guide.select", &json!({}))?; - return Ok(true); - } - ("ArrowLeft", true) | ("ArrowUp", false) => -step, - ("ArrowRight", true) | ("ArrowDown", false) => step, - _ => return Ok(false), - }; - self.execute("guide.move", &at(json!({"index": index, "offset": g.offset + d})))?; - Ok(true) - } - - /// The layer under `p` for a click: the topmost on `page`, or in the focus view the focused - /// frame's. Boolean groups are hit by their results ([`crate::booleans::result_outline`]); in - /// outline mode shapes only on their outlines. - pub fn hit(&self, doc: &Document, page: NodeId, p: Point, tol: f64) -> Option { - let sel = self.selection(); - let result = crate::booleans::result_outline(doc, &sel); - let focus = self.ds().ok().and_then(crate::DocState::focused); - // Outline mode draws no fills: shapes are clicked on their outlines, reaching what's behind. - if let Some(v) = self.ds().ok().map(|d| &d.view).filter(|v| v.outlines) { - // What it draws besides the shapes can be clicked too. - let also = figcraft_doc::geometry::OutlineHits { hidden: v.outline_hidden, bounds: v.outline_bounds }; - return figcraft_doc::geometry::hit_outlines(doc, focus.unwrap_or(page), p, tol, Some(&result), also); - } - match focus { - Some(f) => figcraft_doc::geometry::hit_in(doc, f, p, tol, Some(&result)), - None => doc.hit_test(page, p, tol, Some(&result)), - } - } - - /// Enter with a shape or frame tool (`tool.place`): what a click with it makes, centred on `at` - /// (default the middle of the view) and selected. - pub fn place(&mut self, at: Option) -> Result<()> { - let tool = self.tool.tool; - let refuse = |why: &str| Err(crate::EngineError::Disabled("tool.place".into(), why.into())); - if !tool.draws_shape() { - return refuse("choose a shape or frame tool first"); - } - if self.tool.busy() { - return refuse("finish the drag first"); - } - let v = &self.ds()?.view; - let (c, zoom) = (at.unwrap_or(Point::new(v.cx, v.cy)), v.zoom); - let (w, h) = tool.click_size(); - let ev = |kind| PointerEvent::at(kind, c.x - w / 2.0, c.y - h / 2.0, zoom); - let r = self.pointer(&ev(PointerKind::Down)).and_then(|()| self.pointer(&ev(PointerKind::Up))); - if r.is_err() { - self.tool.gesture = Gesture::None; - let _ = self.cancel_interaction(); - } - r - } - - /// Keyboard Escape: cancel gestures, finish pens, leave text editing, then deselect (in the - /// focus view, once the focused frame or nothing is selected, it ends the focus view). - pub fn escape(&mut self) -> Result<()> { - // Following someone in a live session: Escape stops that first. - if self.live_unfollow() { - return Ok(()); - } - if self.tool.pen.is_some() { - return self.pen_end(); - } - if self.tool.busy() { - self.tool.gesture = Gesture::None; - self.tool.overlay = Overlay::default(); - return self.cancel_interaction(); - } - if self.end_origin_edit() { - return Ok(()); - } - if self.ds()?.text_edit.is_some() { - return textedit::end(self); - } - if self.ds()?.vector_edit.is_some() { - return crate::vectoredit::escape(self); - } - if self.tool.tool != ToolId::Move { - self.set_tool(ToolId::Move); - return Ok(()); - } - let sel = self.selection(); - if let Some(f) = self.ds()?.focused() - && (sel.is_empty() || sel == [f]) - { - self.ds_mut()?.focus = None; - return Ok(()); - } - // Escape selects the parent, then nothing. - let doc = self.doc()?; - let parents: Vec = - sel.iter().filter_map(|i| doc.node(*i).and_then(|n| n.parent)).filter(|p| !doc.node(*p).is_some_and(Node::is_page)).collect(); - if !parents.is_empty() { - let mut uniq = Vec::new(); - for p in parents { - if !uniq.contains(&p) { - uniq.push(p); - } - } - self.set_selection(uniq); - } else { - self.set_selection(vec![]); - } - Ok(()) - } - - /// Whether the Move tool shows arc and shape handles now (not while another gesture runs, nor - /// in vector or text edit mode). - fn arc_handles_shown(&self) -> bool { - let arc_drag = matches!(self.tool.gesture, Gesture::Arc { .. } | Gesture::Shape { .. }); - self.tool.tool == ToolId::Move - && !self.tool.dev_mode - && (arc_drag || !self.tool.busy()) - && self.ds().is_ok_and(|ds| ds.vector_edit.is_none() && ds.text_edit.is_none()) - } - - /// The selected ellipse's arc handles as the canvas shows them at the current zoom. - pub fn arc_handles(&self) -> Option { - let ds = self.ds().ok()?; - self.arc_handles_shown().then(|| crate::arc_handles::handles(&ds.doc, &ds.selection, ds.view.zoom)).flatten() - } - - /// The selected shape's corner radius and star ratio handles as the canvas shows them now. - pub fn shape_handles(&self) -> Vec<(ShapeHandle, [f64; 2])> { - let Ok(ds) = self.ds() else { return vec![] }; - if self.arc_handles_shown() { crate::shape_handles::handles(&ds.doc, &ds.selection, ds.view.zoom) } else { vec![] } - } - - /// Snapshot of tool state for the UI / inspect. - pub fn tool_info(&self) -> serde_json::Value { - let shapes: Vec = self.shape_handles().into_iter().map(|(h, at)| json!({"handle": h, "at": at})).collect(); - json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes}) - } -} - -/// The readout of a shape handle drag on `id`. -fn shape_hint(doc: &Document, id: NodeId, handle: ShapeHandle, zoom: f64) -> Option { - Some(ShapeHint { handle, value: crate::shape_handles::value(doc, id, handle)?, at: crate::shape_handles::handle_at(doc, id, handle, zoom)? }) -} - -#[allow(dead_code)] -fn unused(_: BTreeMap) {} - -/// An end point of a selected open vector within `tol` of page point `p`: (layer, point). -fn selected_end_at(doc: &Document, sel: &[NodeId], p: Point, tol: f64) -> Option<(NodeId, usize)> { - sel.iter().find_map(|id| { - let net = crate::vectoredit::network(doc, *id)?; - let w = doc.world_transform(*id); - let near = |v: &usize| net.vertices.get(*v).is_some_and(|x| (w * x.point() - p).hypot() <= tol); - (0..net.vertices.len()).filter(near).find(|v| net.incident(*v).len() == 1).map(|v| (*id, v)) - }) -} diff --git a/crates/engine/src/canvas/tools/click.rs b/crates/engine/src/canvas/tools/click.rs new file mode 100644 index 00000000..8f21aaca --- /dev/null +++ b/crates/engine/src/canvas/tools/click.rs @@ -0,0 +1,344 @@ +use super::handles::{PATH_REACH_PX, label_at, locked_in, path_candidates, readable_text_fills}; +use super::*; + +impl Session { + /// Handle a double-click gesture: finish a pen path, rename or click labels/widgets, + /// cancel an in-progress move/resize/draw/marquee, handle board double-clicks, enter text + /// editing (select-all on entry, word-select inside the running edit), enter vector editing + /// for selected shapes, and step groups/frames one level deeper. + /// A double-click on `leaf` that a board object or a widget's editable field takes (typing into + /// a sticky or a shape on a board; editing the field like any text, its plugin told when editing + /// ends). Whether it was taken. + fn object_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId], p: Point, edits: bool) -> Result { + if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && self.board_double_click(doc, leaf, sel)? { + return Ok(true); + } + match crate::widgets::field_at(doc, leaf, p).filter(|_| edits) { + Some(field) => textedit::begin(self, field, None, false).map(|()| true), + None => Ok(false), + } + } + + pub(super) fn double_click(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + if self.tool.tool == ToolId::Pen { + return self.pen_finish(); + } + let edits = !self.tool.dev_mode; + // A frame's or section's name label: rename it there (the canvas puts a field over it). + // Labels come before widgets, as on a press; the second press began a move: undo it. + if edits + && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + && !(ev.shift || ev.cmd || ev.alt) + && let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p()) + { + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + if sel != [id] { + self.set_selection(vec![id]); + } + self.request_ui("canvas.renameLabel", serde_json::json!({"id": id})); + return Ok(()); + } + // Quick clicks on a widget's clickable part are clicks, one by one. + if edits + && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + && !(ev.shift || ev.cmd || ev.alt) + && self.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|hit| crate::widgets::target_at(&doc, hit, ev.p())).is_some() + { + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + self.widget_click(ev.p(), ev.tol() * 2.0)?; + return Ok(()); + } + // The second press began a move, a resize, a shape or a marquee: a double-click isn't one. + if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. } | Gesture::Draw { .. } | Gesture::Marquee { .. }) { + self.tool.gesture = Gesture::None; + self.tool.overlay.marquee = None; + self.cancel_interaction()?; + } + let hit_here = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); + // A double-click anywhere on the one selected shape edits its points, like Enter: also + // inside a hollow (unfilled) closed shape, where the hit test finds nothing under the + // pointer (an open path or a line has no inside: only its stroke counts). + if edits + && let [id] = sel[..] + && doc.node(id).is_some_and(|n| n.kind.is_shape() && figcraft_doc::geometry::is_closed(n)) + && doc.world_bounds(id).is_some_and(|b| b.contains(ev.p())) + && hit_here.is_none_or(|h| h == id || doc.is_ancestor(id, h)) + { + return crate::vectoredit::begin_many(self, &[id]); + } + let Some(leaf) = hit_here else { return Ok(()) }; + if self.object_double_click(&doc, leaf, &sel, ev.p(), edits)? { + return Ok(()); + } + let target = select_target(&doc, leaf, &sel, true); + // Text: edit at the click. + if edits + && doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) + && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) + { + // Entering a text selects all of it; in the text being edited, the word. A + // double-click inside the running edit is an ordinary edit: starting a new session + // here would replace the IME owner's epoch and drop a composition on a plain gesture. + let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| te.id == target); + let idx = self.text_index_at(target, ev.p())?; + if editing { + textedit::select_word(self, idx)?; + } else { + textedit::begin(self, target, Some(idx), false)?; + textedit::key(self, "SelectAll", false, false, false)?; + } + return Ok(()); + } + // Shapes and vectors: enter vector edit mode (every selected shape, the clicked one active). + // A shape clicked on its own edits even when the click that selects it and the double-click + // arrived in one frame (a press and a release the canvas took together), so no selection was + // made yet; another layer's shapes are reached one level at a time, as below. + if edits && doc.node(target).is_some_and(|n| n.kind.is_shape()) && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) + { + let mut ids = if sel.contains(&target) { crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default() } else { vec![target] }; + ids.retain(|i| *i != target); + ids.insert(0, target); + return crate::vectoredit::begin_many(self, &ids); + } + // Groups, frames and boolean groups: select one level deeper (the layer under the pointer). + let deeper = figcraft_doc::geometry::enter_target(&doc, leaf, &sel); + if deeper != target || !sel.contains(&deeper) { + self.set_selection(vec![deeper]); + } + Ok(()) + } + + /// A plain click at `p` on a clickable part of a widget calls its plugin back (a failure is + /// shown as a message). + pub(super) fn widget_click(&mut self, p: Point, tol: f64) -> Result<()> { + let (doc, page) = (self.doc()?.clone(), self.page()?); + let Some((id, target)) = self.hit(&doc, page, p, tol).and_then(|hit| crate::widgets::target_at(&doc, hit, p)) else { return Ok(()) }; + if let Err(e) = self.execute("widget.click", &serde_json::json!({"id": id, "target": target})) { + self.message = Some(e.to_string()); + } + Ok(()) + } + + /// The char index in text node `id` at page point `p`. + pub fn text_index_at(&self, id: NodeId, p: Point) -> Result { + let doc = self.doc()?; + let n = doc.get(id)?; + let local = doc.world_transform(id).inverse() * p; + let Some((layout, dy)) = figcraft_text::layout_node(n) else { return Ok(0) }; + Ok(layout.hit(local.x, local.y - dy)) + } + + pub(super) fn text_click(&mut self, id: NodeId, ev: &PointerEvent, extend: bool) -> Result<()> { + let idx = self.text_index_at(id, ev.p())?; + textedit::set_caret(self, idx, extend)?; + self.tool.gesture = Gesture::TextSelect { id }; + Ok(()) + } + + pub(super) fn text_down(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let (doc, page) = (ds.doc.clone(), ds.page); + if let Some(te) = ds.text_edit.clone() { + if doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + // Clicking existing text edits it. + if let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) + && doc.node(leaf).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) + { + let idx = self.text_index_at(leaf, ev.p())?; + textedit::begin(self, leaf, Some(idx), false)?; + self.tool.gesture = Gesture::TextSelect { id: leaf }; + return Ok(()); + } + // New text at the click (Figma puts the baseline-ish top-left there). + let parent = frame_at(&doc, page, ev.p(), &[]).unwrap_or(page); + let ds = self.ds_mut()?; + let d = ds.doc_mut(); + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", 1.0, 14.0); + let pw = d.world_transform(parent); + let at = Point::new(ev.x.round(), (ev.y - 7.0).round()); + n.transform = pw.inverse() * Affine::translate(at.to_vec2()); + n.name = String::new(); + if let Some(fills) = readable_text_fills(d, parent) { + n.fills = fills; + } + if let Some((w, h)) = figcraft_text::auto_size(&n) { + n.width = w.max(1.0); + n.height = h; + } + let line = n.height; + d.insert(n, parent, None)?; + ds.selection = vec![id]; + textedit::begin(self, id, Some(0), true)?; + self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line }; + if !self.prefs.keep_tool { + self.tool.tool = ToolId::Move; + } + Ok(()) + } + + /// The layer whose path is under `ev` for the Text on a path tool and where along it the press + /// is (0..1 by length): the front-most shape or vector layer within a few screen pixels of its + /// first subpath (the one text follows), not locked and not inside an instance. Layers in front + /// whose path is further away don't hide it. + pub(crate) fn path_at(&self, doc: &Document, page: NodeId, ev: &PointerEvent) -> Option<(NodeId, f64)> { + let reach = ev.tol() * PATH_REACH_PX; + let near = |id: NodeId| -> Option<(NodeId, f64)> { + let n = doc.node(id)?; + let shape = matches!( + n.kind, + NodeKind::Rectangle + | NodeKind::Ellipse { .. } + | NodeKind::Polygon { .. } + | NodeKind::Star { .. } + | NodeKind::Line + | NodeKind::Vector { .. } + ); + if !shape || locked_in(doc, id) || crate::components::inside_instance(doc, id) { + return None; + } + let geom = figcraft_doc::geometry::stroke_geometry(n)?; + let line = figcraft_geom::measure::polylines(&geom)?.into_iter().next()?; + // Measured on the page (a layer scaled more one way than the other is still reached + // where it shows), the start by length along the layer's own path. + let world = doc.world_transform(id); + let p = ev.p(); + let mut best: Option<(f64, f64)> = None; + for (i, w) in line.points.windows(2).enumerate() { + let (a, b) = (world * w[0], world * w[1]); + let d = b - a; + let l2 = d.hypot2(); + let u = if l2 > 1e-18 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let dist = (a.lerp(b, u) - p).hypot(); + if best.is_none_or(|(_, bd)| dist < bd) { + let (la, lb) = (*line.lengths.get(i)?, *line.lengths.get(i + 1)?); + best = Some((la + u * (lb - la), dist)); + } + } + let (along, dist) = best?; + let len = line.length(); + (len > 1e-9 && dist <= reach).then_some((id, along / len)) + }; + let mut candidates = Vec::new(); + path_candidates(doc, page, ev.p(), reach, 0, &mut candidates); + candidates.into_iter().find_map(near) + } + + /// A press with the Text on a path tool: on a path, a new empty text layer goes onto it, + /// starting where it was pressed, and typing starts. The text takes the path layer's place, + /// geometry, transform, constraints and effects, and its fills when one of them shows; the path + /// layer is removed. Ending the edit with nothing typed puts the path back without an undo step + /// ([`textedit::end`]). Elsewhere a press does nothing. + pub(super) fn text_on_path_down(&mut self, ev: &PointerEvent) -> Result<()> { + if let Some(te) = self.ds()?.text_edit.clone() { + if self.ds()?.doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + let ds = self.ds()?; + let (doc, page) = (ds.doc.clone(), ds.page); + let Some((path, start)) = self.path_at(&doc, page, ev) else { return Ok(()) }; + let Some(pn) = doc.node(path) else { return Ok(()) }; + let (Some(parent), Some(geom)) = (pn.parent, figcraft_doc::geometry::stroke_geometry(pn)) else { return Ok(()) }; + let index = doc.index_in_parent(path); + self.begin_interaction("Text on a path")?; + let ds = self.ds_mut()?; + let d = ds.doc_mut(); + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", pn.width, pn.height); + n.name = String::new(); + n.transform = pn.transform; + n.constraints = pn.constraints; + n.child_layout = pn.child_layout.clone(); + let effects = !pn.effects.is_empty(); + if effects { + n.effects = pn.effects.clone(); + n.styles.effect = pn.styles.effect.clone(); + } + // A path drawn with a stroke only has no fill to give: the text gets the usual one. + let filled = pn.fills.iter().any(|f| f.visible); + if filled { + n.fills = pn.fills.clone(); + n.styles.fill = pn.styles.fill.clone(); + } else if let Some(fills) = readable_text_fills(d, parent) { + n.fills = fills; + } + // As `text.onPath`: a binding goes with the list it binds into (`fill`, `fills`, `fills/N/…`). + let carried = |k: &str| match crate::cmd::lists::list_binding(k) { + Some((crate::cmd::lists::BoundList::Fills, ..)) => filled, + Some((crate::cmd::lists::BoundList::Effects, ..)) => effects, + _ => false, + }; + n.bound = pn.bound.iter().filter(|(k, _)| carried(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); + if let NodeKind::Text(t) = &mut n.kind { + let mut tp = TextPath::new(&geom); + tp.start = start; + t.path = Some(Box::new(tp)); + } + d.insert(n, parent, index)?; + d.remove(path)?; + ds.selection = vec![id]; + textedit::begin(self, id, Some(0), true)?; + if !self.prefs.keep_tool { + self.tool.tool = ToolId::Move; + } + Ok(()) + } + + /// Drag out a new text box: past the drag slop it has the drag's size (at least one line + /// tall) and its top-left, and that size is fixed. + pub(super) fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> { + if within_slop(ev, start) { + return Ok(()); + } + let r = Rect::from_points(start, ev.p()); + let d = self.ds_mut()?.doc_mut(); + let pw = d.node(id).and_then(|n| n.parent).map(|p| d.world_transform(p)).unwrap_or_default(); + let n = d.get_mut(id)?; + n.transform = pw.inverse() * Affine::translate((r.x0.round(), r.y0.round())); + n.width = r.width().round().max(1.0); + n.height = r.height().round().max(line); + n.child_layout.sizing_h = Sizing::Fixed; + n.child_layout.sizing_v = Sizing::Fixed; + if let NodeKind::Text(t) = &mut n.kind { + t.auto_resize = TextAutoResize::None; + } + self.ds_mut()?.revision += 1; + Ok(()) + } + + pub(super) fn eyedrop(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let doc = ds.doc.clone(); + let view = figcraft_render::View { origin: (ev.x, ev.y), scale: ev.zoom.max(0.01), width: 1, height: 1 }; + let Some(pix) = figcraft_render::render_view(&doc, ds.page, &view, &Default::default()) else { return Ok(()) }; + let rgba = figcraft_render::to_rgba8(&pix); + let c = figcraft_color::Color::from_rgba8( + rgba.first().copied().unwrap_or(0), + rgba.get(1).copied().unwrap_or(0), + rgba.get(2).copied().unwrap_or(0), + 255, + ); + let sel = ds.selection.clone(); + if self.tool.pick_only { + self.tool.picked = Some(c); + } else if !sel.is_empty() { + self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?; + } + self.recent_colors.retain(|x| *x != c); + self.recent_colors.insert(0, c); + self.recent_colors.truncate(16); + self.message = Some(format!("Picked #{}", c.to_hex())); + self.set_tool(ToolId::Move); + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs new file mode 100644 index 00000000..0d406673 --- /dev/null +++ b/crates/engine/src/canvas/tools/drag.rs @@ -0,0 +1,605 @@ +use super::handles::{ + SNAP_PX, handle_at, in_quad, label_at, locked_in, on_corner_square, paints_below, resized_box, scale_factor, selection_box, selection_frame, + shape_hint, +}; +use super::*; + +impl Session { + pub(super) fn move_down(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + // Clicking outside the text being edited ends editing. + if let Some(te) = ds.text_edit.clone() { + let inside = doc.world_bounds(te.id).is_some_and(|b| b.inflate(ev.tol() * 4.0, ev.tol() * 4.0).contains(ev.p())); + if inside { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + // Handles first: a selected connector's ends, a selected ellipse's arc handles (drawn over + // the box), then the box's. + let edits = !self.tool.dev_mode; + if edits && let Some((id, from)) = crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom) { + self.tool.gesture = Gesture::Reattach { id, from, start: ev.p() }; + return Ok(()); + } + if self.arc_handles_shown() + && let Some((id, handle)) = crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom) + && let Some(arc) = crate::arc_handles::arc_of(&doc, id) + { + self.begin_interaction("Arc")?; + self.tool.gesture = Gesture::Arc { id, handle, arc }; + self.tool.overlay.arc = + crate::arc_handles::handle_at(&doc, id, handle, arc, ev.zoom).map(|at| ArcHint { handle, label: arc.label(handle), at }); + return Ok(()); + } + if self.arc_handles_shown() + && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) + && let Some((id, handle)) = crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom) + { + self.begin_interaction("Corner radius")?; + self.tool.gesture = Gesture::Shape { id, handle }; + self.tool.overlay.shape = shape_hint(&doc, id, handle, ev.zoom); + return Ok(()); + } + if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| edits) { + let ids = roots(&doc, &sel); + if let Handle::Rotate(_) = h { + let Some(b) = selection_box(&doc, &ids) else { return Ok(()) }; + let center = self + .ds()? + .moved_origin() + .unwrap_or_else(|| Point::new(b.iter().map(|c| c.x).sum::() / 4.0, b.iter().map(|c| c.y).sum::() / 4.0)); + self.begin_interaction("Rotate")?; + self.tool.gesture = Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), ids }; + return Ok(()); + } + let nodes: Vec<(NodeId, Affine, f64, f64)> = + ids.iter().filter_map(|i| doc.node(*i).map(|n| (*i, doc.world_transform(*i), n.width, n.height))).collect(); + let Some(frame) = selection_frame(&doc, &ids) else { return Ok(()) }; + let single = ids.len() == 1; + self.begin_interaction(if self.tool.tool == ToolId::Scale { "Scale" } else { "Resize" })?; + self.tool.gesture = Gesture::Resize { + start: ev.p(), + r: Box::new(ResizeStart { nodes, frame, single, handle: h }), + scale_tool: self.tool.tool == ToolId::Scale, + moved: false, + }; + return Ok(()); + } + // A frame's name label picks the frame itself. The background of a top-level frame with + // layers in it is like the canvas (a press starts a marquee inside it) unless the frame is + // selected; an empty frame (or a widget, or a board object such as a sticky) is picked and + // dragged. + let board_background = |leaf: NodeId| { + !sel.contains(&leaf) + && doc.node(leaf).is_some_and(|n| { + n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n) && !n.is_board_atom() + }) + }; + let label = label_at(&doc, page, &self.tool.labels, ev.p()); + let target = label.or_else(|| { + self.hit(&doc, page, ev.p(), ev.tol() * 2.0) + .filter(|leaf| ev.cmd || !board_background(*leaf)) + .map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)) + }); + // A press inside the selection's box that reaches nothing above it (an unfilled shape's + // inside, the frame or canvas behind it, or a layer stacked below the selected layer the + // press is in) drags the selection. In a gap between selected layers, what's there is hit. + let in_own_box = |s: NodeId| selection_box(&doc, &[s]).is_some_and(|q| in_quad(&q, ev.p())); + let behind_sel = |t: Option| { + t.is_none_or(|t| sel.iter().any(|s| *s != t && (doc.is_ancestor(t, *s) || (in_own_box(*s) && paints_below(&doc, t, *s))))) + }; + let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) { + Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => { + Some(first) + } + _ => target, + }; + match target { + Some(target) => { + // Clicking inside the current selection's box keeps it (drag them all). + let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift; + // Shift on a selected layer takes it out on release if the press doesn't become a + // drag; a drag moves the selection with it. Where no drag can start, it goes now. + let deselect = (ev.shift && sel.contains(&target)).then_some(target); + let without_target = || sel.iter().copied().filter(|x| *x != target).collect::>(); + let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) { + sel.clone() + } else if ev.shift && deselect.is_none() { + let mut s2 = sel.clone(); + s2.push(target); + s2 + } else if sel.contains(&target) { + sel.clone() + } else { + vec![target] + }; + self.set_selection(new_sel.clone()); + // Dev Mode only selects (Shift-click still deselects): nothing moves. + if !edits { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Ok(()); + } + // Locked layers selected in the layers panel stay put. + let ids: Vec = roots(&doc, &new_sel).into_iter().filter(|i| !locked_in(&doc, *i)).collect(); + if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Ok(()); + } + let mut targets = Targets::gather(&doc, page, &ids); + // A copy dragged out with Alt snaps to the layers it came from too (they stay put). + if ev.alt + && let Some(b) = targets.moving + { + targets.add_box(b); + } + if let Err(e) = self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" }) { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Err(e); + } + self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect }; + self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default }; + } + None => { + let within = self.marquee_within(ev.p())?; + if !ev.shift { + self.set_selection(vec![]); + } + self.tool.gesture = Gesture::Marquee { start: ev.p(), additive: ev.shift, base: if ev.shift { sel } else { vec![] }, within }; + } + } + Ok(()) + } + + pub(super) fn drag(&mut self, ev: &PointerEvent) -> Result<()> { + 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 } => { + let r = Rect::from_points(*start, ev.p()); + self.tool.overlay.marquee = Some([r.x0, r.y0, r.x1, r.y1]); + // Cmd/Ctrl: the nested layers it touches. + let sel = self.marquee_selection(r, *within, ev.cmd, base, *additive)?; + self.set_selection(sel); + Ok(()) + } + Gesture::Move { start, moved: false, .. } | Gesture::Resize { start, moved: false, .. } if within_slop(ev, *start) => Ok(()), + Gesture::Move { start, ids, targets, duplicate, .. } => { + let mut d = ev.p() - *start; + if ev.shift { + if d.x.abs() > d.y.abs() { + d.y = 0.0; + } else { + d.x = 0.0; + } + } + // Cmd/Ctrl (and Control on macOS) held while dragging suspends snapping. + let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; + let mut guides = Vec::new(); + if snap_on { + let (c, g2) = targets.snap_move(d, SNAP_PX / ev.zoom.max(1e-6)); + d += c; + guides = g2; + } + if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) { + // Keep the box's origin on whole pixels (a rotated parent frame's, inside one). + d = targets.round_move(d); + } + let ids = ids.clone(); + let dup = *duplicate; + let page = self.page()?; + let mut drop_cell = None; + let mut drop_target = None; + let mut in_flow = false; + // Placed in a row or column: (is it a column, its gap) for the insertion line. + let mut slot_axis: Option<(bool, f64)> = None; + self.preview(|doc, sel| { + let mut moving = ids.clone(); + if dup { + let mut copies = Vec::new(); + for i in &ids { + let parent = doc.get(*i)?.parent.unwrap_or(page); + let idx = doc.index_in_parent(*i).map(|x| x + 1); + copies.push(crate::components::copy_in_file(doc, *i, parent, idx)?); + } + // Connectors copied with what they join join the copies. + let map = crate::board::copy_map(doc, &ids, &copies); + crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false); + moving = copies; + *sel = moving.clone(); + } + let p = ev.p(); + let home = moving.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent); + // Absolute children of an auto layout frame stay in it, and so do the layers of + // groups, boolean groups and instances ([`crate::drop::keeps_layers`]); the + // rest may change parent. + let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute)) + && !home.is_some_and(|h| crate::drop::keeps_layers(doc, h)); + let target = if flow { crate::drop::drag_parent(doc, page, p, &moving) } else { home.unwrap_or(page) }; + let mode = doc.node(target).and_then(|n| n.layout.as_ref()).map(|l| l.mode); + if let (true, Some(mode)) = (flow, mode) { + // Into (or within) an auto layout frame: the layers take the slot (a grid: the + // cell) under the pointer, as the frame shows it now. A frame other than the + // one they come from is outlined too. + let cell = if mode == LayoutMode::Grid { crate::drop::grid_cell_at(doc, target, p) } else { None }; + let idx = + if mode == LayoutMode::Grid { 0 } else { crate::drop::auto_layout_slot(doc, target, p, &moving).map_or(0, |(i, _)| i) }; + for m in &moving { + if doc.get(*m)?.parent != Some(target) { + doc.reparent(*m, target, None)?; + } + } + if let (Some(first), Some((col, row, rect))) = (moving.first().copied(), cell) { + let cl = &mut doc.get_mut(first)?.child_layout; + cl.grid_column = Some(col); + cl.grid_row = Some(row); + drop_cell = Some(rect); + } else if mode != LayoutMode::Grid { + let mut kids: Vec = doc.get(target)?.children.iter().copied().filter(|c| !moving.contains(c)).collect(); + for (k, m) in moving.iter().enumerate() { + kids.insert((idx + k).min(kids.len()), *m); + } + let gap = doc.node(target).and_then(|n| n.layout.as_ref()).map_or(0.0, |l| l.gap); + doc.get_mut(target)?.children = kids; + slot_axis = Some((mode == LayoutMode::Vertical, gap)); + } + drop_target = (Some(target) != home).then_some(target); + in_flow = true; + return Ok(()); + } + // A free move. Layers dragged out of an auto layout flow leave it (onto the page + // while dragging; the drop puts them in the frame under the pointer). + for m in &moving { + let in_al = doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); + if flow && in_al { + doc.reparent(*m, page, None)?; + } + move_world(doc, *m, d.x, d.y)?; + } + drop_target = (flow && target != page).then_some(target); + Ok(()) + })?; + 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). + 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)) + }); + self.tool.overlay.drop_cell = drop_cell; + self.tool.overlay.drop_target = drop_target; + self.tool.overlay.cursor = if self.refused(&placed, true) { + Cursor::NotAllowed + } else if dup { + Cursor::Copy + } else { + Cursor::Default + }; + self.tool.gesture = g.clone(); + if let Gesture::Move { moved, .. } = &mut self.tool.gesture { + *moved = true; + } + return Ok(()); + } + Gesture::Resize { start, r, scale_tool, .. } => { + let res = self.resize_drag(ev, *start, r, *scale_tool); + self.tool.gesture = Gesture::Resize { start: *start, r: r.clone(), scale_tool: *scale_tool, moved: true }; + if self.refused(&self.selection(), false) { + self.tool.overlay.cursor = Cursor::NotAllowed; + } + return res; + } + Gesture::Rotate { center, start_angle, ids } => { + let mut a = ((ev.p() - *center).atan2() - start_angle).to_degrees(); + if ev.shift { + a = (a / 15.0).round() * 15.0; + } + let (c, ids) = (*center, ids.clone()); + self.preview(|doc, _| { + for i in &ids { + crate::cmd::arrange::rotate_world(doc, *i, c, -a)?; + } + Ok(()) + })?; + // The cursor turns with the pointer about the centre. + self.tool.overlay.cursor = + if self.refused(&ids, false) { Cursor::NotAllowed } else { Cursor::Rotate((ev.p() - c).atan2().to_degrees()) }; + Ok(()) + } + Gesture::Draw { start, id, parent, targets } => { + let (start, id, parent, targets) = (*start, *id, *parent, targets.clone()); + self.tool.gesture = g; + return self.draw_drag(ev, start, id, parent, &targets); + } + Gesture::TextBox { id, start, line } => { + let (id, start, line) = (*id, *start, *line); + self.tool.gesture = g; + return self.text_box_drag(ev, id, start, line); + } + Gesture::Arc { id, handle, arc } => { + let (id, handle, arc) = (*id, *handle, *arc); + self.tool.gesture = g; + return self.arc_drag(ev, id, handle, arc); + } + Gesture::Shape { id, handle } => { + let (id, handle) = (*id, *handle); + let doc = self.doc()?.clone(); + if let Some(props) = crate::shape_handles::drag(&doc, id, handle, ev.p(), ev.alt) { + self.preview(|doc, _| crate::cmd::props::apply("node.set", doc, id, &props))?; + } + self.tool.overlay.shape = shape_hint(self.doc()?, id, handle, ev.zoom); + self.tool.overlay.shape_hover = Some(handle); + Ok(()) + } + Gesture::Pan { sx, sy, cx, cy } => { + let ds = self.ds_mut()?; + let z = ds.view.zoom.max(1e-6); + ds.view.cx = cx - (ev.sx - sx) / z; + ds.view.cy = cy - (ev.sy - sy) / z; + ds.revision += 1; + Ok(()) + } + Gesture::Pencil { points, pressure } => { + let (mut pts, mut pressure) = (points.clone(), pressure.clone()); + if pts.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { + pts.push(ev.p()); + pressure.push(ev.pressure); + } + self.tool.overlay.pen = pts.iter().map(|p| [p.x, p.y]).collect(); + self.tool.gesture = Gesture::Pencil { points: pts, pressure }; + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + return Ok(()); + } + Gesture::TextSelect { id } => { + let id = *id; + let idx = self.text_index_at(id, ev.p())?; + textedit::set_caret(self, idx, true)?; + Ok(()) + } + Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()), + Gesture::Connect { from, start } => { + let (from, start) = (*from, *start); + self.connect_drag(ev, from, start); + return Ok(()); + } + Gesture::Erase { gone } => { + self.tool.gesture = Gesture::Erase { gone: gone.clone() }; + return self.erase_at(ev); + } + Gesture::Reattach { id, from, start } => { + let (id, from, start) = (*id, *from, *start); + self.reattach_drag(ev, id, from, start); + return Ok(()); + } + Gesture::PenDrag { anchor } => { + // A click's wobble pulls out no handle. + let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor }; + if let Some(pen) = self.tool.pen.as_mut() + && let Some(last) = pen.points.last_mut() + { + last.2 = out; + last.1 = -out; + } + self.pen_update()?; + Ok(()) + } + }; + self.tool.gesture = g; + res + } + + /// An arc handle drag: the new arc, written through `node.set`'s keys. + fn arc_drag(&mut self, ev: &PointerEvent, id: NodeId, handle: ArcHandle, arc: crate::arc_handles::Arc) -> Result<()> { + let doc = self.doc()?.clone(); + let Some(next) = crate::arc_handles::drag(&doc, id, handle, arc, ev.p(), ev.shift, ev.zoom) else { return Ok(()) }; + let props = crate::arc_handles::props(next); + self.preview(|doc, _| crate::cmd::props::apply("node.set", doc, id, &props))?; + self.tool.gesture = Gesture::Arc { id, handle, arc: next }; + let at = crate::arc_handles::handle_at(self.doc()?, id, handle, next, ev.zoom); + self.tool.overlay.arc = at.map(|at| ArcHint { handle, label: next.label(handle), at }); + self.tool.overlay.arc_hover = Some(handle); + Ok(()) + } + + fn resize_drag(&mut self, ev: &PointerEvent, start: Point, r: &ResizeStart, scale_tool: bool) -> Result<()> { + let page = self.page()?; + let snap = self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; + let ids: Vec = r.nodes.iter().map(|n| n.0).collect(); + let targets = if snap { Some(Targets::gather(self.doc()?, page, &ids)) } else { None }; + let mut p = ev.p(); + let mut guides = Vec::new(); + if let Some(t) = &targets { + let (dx, dy, g) = t.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); + p.x += dx; + p.y += dy; + guides = g; + } + let _ = start; + // In the handle box's own axes (one layer: its local space). + let (frame, w0, h0) = r.frame; + // The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool + // scales by the factor as dragged). + if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) { + p = Point::new(p.x.round(), p.y.round()); + } + let q = frame.inverse() * p; + let is_line = r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false); + if scale_tool { + 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 { + Handle::Corner(0) => (p, frame * Point::new(w0, 0.0)), + _ => (frame * Point::new(0.0, 0.0), p), + }; + let id = ids.first().copied().unwrap_or(NodeId(0)); + self.preview(|doc, _| { + let mut v = b - a; + if ev.shift { + let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; + v = Vec2::from_angle(ang) * v.hypot(); + } + let len = v.hypot(); + let world = Affine::translate(a.to_vec2()) * Affine::rotate(v.atan2()); + doc.set_world_transform(id, world)?; + figcraft_layout::set_size(doc, id, len, 0.0); + Ok(()) + })?; + self.tool.overlay.guides = guides; + 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); + 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)); + let (nw, nh) = ((x1 - x0).abs(), (y1 - y0).abs()); + let nodes = r.nodes.clone(); + let single = r.single; + let handle = r.handle; + // What's in a section stays where it is on the page, and so does what's in a frame resized + // with Cmd/Ctrl held (its constraints are ignored). + let keeps = |doc: &Document, id: NodeId| { + doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }) || (ev.cmd && single && n.kind.is_frame_like())) + }; + self.preview(|doc, _| { + if single { + let Some((id, world, _, _)) = nodes.first() else { return Ok(()) }; + let kept = if keeps(doc, *id) { crate::cmd::props::children_worlds(doc, *id) } else { Vec::new() }; + let mut t = *world * Affine::translate((nx0, ny0)); + if flip_x { + t *= Affine::new([-1.0, 0.0, 0.0, 1.0, nw, 0.0]); + } + if flip_y { + t *= Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, nh]); + } + // Text reflow and auto layout can recompute this box after the preview (a size bound + // to a variable lets go of it: the resize is an explicit edit). + let n = doc.get(*id)?; + let crop_images = ev.cmd + && !matches!(n.kind, NodeKind::Text(_)) + && !std::iter::once(*id).chain(doc.ancestors(*id)).any(|a| doc.node(a).is_some_and(|n| n.layout.is_some())); + if crop_images { + // Crop the layer's image fills without moving their pixels on the page. + // Work from the interaction snapshot, just like the geometry on every move. + let (w, h) = (n.width.max(1e-6), n.height.max(1e-6)); + let to_old = + Affine::scale_non_uniform(1.0 / w, 1.0 / h) * world.inverse() * t * Affine::scale_non_uniform(nw.max(0.01), nh.max(0.01)); + let mut fills = n.fills.clone(); + for paint in &mut fills { + let PaintKind::Image(img) = &mut paint.kind else { continue }; + // A repeating image cannot be represented by a single Crop placement. + if img.scale_mode == ImageScaleMode::Tile { + continue; + } + let Some(data) = doc.images.get(&img.image) else { continue }; + let (iw, ih) = (f64::from(data.width), f64::from(data.height)); + let crop = if img.scale_mode == ImageScaleMode::Crop { + img.effective_crop(iw, ih, w, h) + } else { + let (rw, rh) = img.rotated_size(iw.max(1.0), ih.max(1.0)); + let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) }; + [w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)] + }; + // A degenerate (zero-scale) layer has no inverse: its image keeps its placement. + let crop = (Affine::new(crop) * to_old).as_coeffs(); + if !crop.iter().all(|c| c.is_finite()) { + continue; + } + img.crop = Some(crop); + img.scale_mode = ImageScaleMode::Crop; + } + doc.get_mut(*id)?.fills = fills; + } + doc.set_world_transform(*id, t)?; + if kept.is_empty() { + crate::cmd::props::resize_node(doc, *id, nw.max(0.01), nh.max(0.01))?; + } else { + figcraft_layout::set_size(doc, *id, nw.max(0.01), nh.max(0.01)); + crate::cmd::props::restore_worlds(doc, &kept)?; + } + 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 { + match handle { + Handle::Edge(1) | Handle::Edge(3) if t.auto_resize == TextAutoResize::Truncate => {} + Handle::Edge(1) | Handle::Edge(3) => t.auto_resize = TextAutoResize::Height, + _ => t.fix_size(true, true), + } + } + return Ok(()); + } + // Several layers: map the old box onto the new one, in the box's axes. Each layer's + // centre follows the box (mirrored when it flips); each keeps its rotation and scales + // along its own axes (see `axis_factors`), turning over with the box. + let (sx, sy) = (if w0 > 1e-9 { (x1 - x0) / w0 } else { 1.0 }, if h0 > 1e-9 { (y1 - y0) / h0 } else { 1.0 }); + let mirror = Affine::new([sx.signum(), 0.0, 0.0, sy.signum(), 0.0, 0.0]); + let inv = frame.inverse(); + for (id, world, w, h) in &nodes { + let local = inv * *world; + let center = local * Point::new(w / 2.0, h / 2.0); + let (kw, kh) = figcraft_layout::axis_factors(local, sx, sy); + let (w2, h2) = (w * kw, h * kh); + let lin = (mirror * local).with_translation(Vec2::ZERO); + let origin = Point::new(x0 + center.x * sx, y0 + center.y * sy) - lin * Point::new(w2 / 2.0, h2 / 2.0); + let kept = if keeps(doc, *id) { crate::cmd::props::children_worlds(doc, *id) } else { Vec::new() }; + doc.set_world_transform(*id, frame * lin.with_translation(origin))?; + crate::cmd::props::resize_node(doc, *id, w2, h2)?; + crate::cmd::props::restore_worlds(doc, &kept)?; + } + Ok(()) + })?; + self.tool.overlay.guides = guides; + 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(()) + } +} diff --git a/crates/engine/src/canvas/tools/draw.rs b/crates/engine/src/canvas/tools/draw.rs new file mode 100644 index 00000000..b594ae27 --- /dev/null +++ b/crates/engine/src/canvas/tools/draw.rs @@ -0,0 +1,229 @@ +use super::handles::SNAP_PX; +use super::pen::take_enclosed; +use super::*; + +impl Session { + pub(super) fn draw_down(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let doc = ds.doc.clone(); + let page = ds.page; + if ds.text_edit.is_some() { + textedit::end(self)?; + } + // Frames drawn inside frames nest; sections only at the top or in sections. + let parent = match self.tool.tool { + ToolId::Section => { + frame_at(&doc, page, ev.p(), &[]).filter(|f| doc.node(*f).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))).unwrap_or(page) + } + _ => frame_at(&doc, page, ev.p(), &[]).unwrap_or(page), + }; + let targets = Targets::gather(&doc, page, &[]); + self.begin_interaction(self.tool.tool.label())?; + self.tool.gesture = Gesture::Draw { start: ev.p(), id: None, parent, targets }; + Ok(()) + } + + pub(super) fn draw_drag(&mut self, ev: &PointerEvent, start: Point, id: Option, parent: NodeId, targets: &Targets) -> Result<()> { + let tool = self.tool.tool; + let mut p = ev.p(); + let mut guides = Vec::new(); + if self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl { + let (dx, dy, g) = targets.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); + p.x += dx; + p.y += dy; + guides = g; + } + let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true); + let mut created = id; + let page = self.page()?; + self.preview(|doc, sel| { + let (mut a, mut b) = (start, p); + if pixel { + a = Point::new(a.x.round(), a.y.round()); + b = Point::new(b.x.round(), b.y.round()); + } + let pw = doc.world_transform(parent); + let (kind, base) = match tool { + ToolId::Frame => (NodeKind::Frame, "Frame"), + ToolId::Section => (NodeKind::Section { contents_hidden: false }, "Section"), + ToolId::Slice => (NodeKind::Slice, "Slice"), + ToolId::Rectangle => (NodeKind::Rectangle, "Rectangle"), + ToolId::Ellipse => (NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Ellipse"), + ToolId::Polygon => (NodeKind::Polygon { count: 3 }, "Polygon"), + ToolId::Star => (NodeKind::Star { count: 5, inner_ratio: 0.38 }, "Star"), + ToolId::Line => (NodeKind::Line, "Line"), + ToolId::Arrow => (NodeKind::Line, "Arrow"), + _ => return Ok(()), + }; + let name = match tool { + ToolId::Frame | ToolId::Section | ToolId::Slice => doc.next_name(base), + _ => base.to_string(), + }; + let nid = doc.alloc_id(); + let mut n = if tool == ToolId::Section { Node::new_section(nid, name, 0.0, 0.0) } else { Node::new(nid, kind, name, 0.0, 0.0) }; + if tool == ToolId::Arrow { + n.stroke.cap_end = StrokeCap::LineArrow; + } + if matches!(tool, ToolId::Line | ToolId::Arrow) { + let mut v = b - a; + if ev.shift { + let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; + v = Vec2::from_angle(ang) * v.hypot(); + } + // Alt draws from the centre, as for the other shapes. + let (a, v) = if ev.alt { (a - v, v * 2.0) } else { (a, v) }; + n.width = v.hypot(); + n.transform = pw.inverse() * Affine::translate(a.to_vec2()) * Affine::rotate(v.atan2()); + } else { + let mut d = b - a; + if ev.shift { + let m = d.x.abs().max(d.y.abs()); + d = Vec2::new(m * d.x.signum(), m * d.y.signum()); + } + let (x0, y0, x1, y1) = if ev.alt { (a.x - d.x, a.y - d.y, a.x + d.x, a.y + d.y) } else { (a.x, a.y, a.x + d.x, a.y + d.y) }; + let r = Rect::new(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); + n.width = r.width(); + n.height = r.height(); + n.transform = pw.inverse() * Affine::translate((r.x0, r.y0)); + } + doc.insert(n, parent, None)?; + *sel = vec![nid]; + created = Some(nid); + let _ = page; + Ok(()) + })?; + self.tool.overlay.guides = guides; + self.tool.gesture = Gesture::Draw { start, id: created, parent, targets: targets.clone() }; + Ok(()) + } + + pub(super) fn up(&mut self, ev: &PointerEvent) -> Result<()> { + 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.arc = None; + self.tool.overlay.shape = 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(); + match g { + Gesture::Move { ids, moved, duplicate, start, deselect, .. } => { + if moved { + // A free move lands in the frame under the pointer (or the page) where it was + // dropped; layers in an auto layout frame already sit in their slot. + let page = self.page()?; + let sel = self.selection(); + let sources = if duplicate { ids.clone() } else { Vec::new() }; + let moving = if duplicate { sel } else { ids }; + let doc = self.doc()?.clone(); + let in_al = + moving.iter().any(|m| doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); + let target = drop_target.unwrap_or(page); + let current = moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent); + if !in_al && current != Some(target) { + let ds = self.ds_mut()?; + let before = ds.doc.clone(); + let d = ds.doc_mut(); + for m in &moving { + // Group, boolean group and instance layers stay put: they only move. + let from = d.node(*m).and_then(|n| n.parent); + if from.is_some_and(|f| crate::drop::keeps_layers(d, f)) || crate::components::structure_locked(d, target) { + continue; + } + // A layer can't go into itself; it stays where it is. + let _ = d.reparent(*m, target, None); + } + crate::normalize(ds.doc_mut(), &before); + } + // Reordering or dragging layers out of an instance (outside its slots), or + // moving layers inside one, are edits only its main can make: the gesture is + // undone. + if self.refused(&moving, true) { + self.cancel_interaction()?; + } else { + self.commit_interaction()?; + // Duplicate repeats a free Alt-drag's step (an auto layout places its own). + if duplicate && !in_al && moving.len() == sources.len() { + self.ds_mut()?.last_duplicate = Some(crate::cmd::edit::Duplicated { sources, copies: moving }); + } + } + } else { + self.cancel_interaction()?; + if let Some(d) = deselect { + let sel = self.selection().into_iter().filter(|x| *x != d).collect(); + self.set_selection(sel); + } + if deselect.is_none() && !(ev.shift || ev.cmd || duplicate) { + self.widget_click(start, ev.tol() * 2.0)?; + } + } + } + Gesture::Resize { .. } | Gesture::Rotate { .. } | Gesture::Arc { .. } | Gesture::Shape { .. } => { + // Layers inside an instance keep their main's size and rotation. + if self.refused(&self.selection(), false) { + self.cancel_interaction()?; + } else { + self.commit_interaction()?; + } + } + Gesture::Draw { start, id, parent, .. } => { + let tiny = (ev.p() - start).hypot() * ev.zoom < 3.0; + if tiny || id.is_none() { + // Click without drag: Figma makes a 100×100 shape (frames 100×100 too). + self.cancel_interaction()?; + self.begin_interaction(self.tool.tool.label())?; + let (w, h) = self.tool.tool.click_size(); + let target = start + Vec2::new(w, h); + let mut fake = ev.clone(); + fake.x = target.x; + fake.y = target.y; + fake.shift = false; + fake.alt = false; + self.tool.gesture = Gesture::None; + let targets = Targets::default(); + self.draw_drag(&fake, start, None, parent, &targets)?; + self.tool.gesture = Gesture::None; + } + if matches!(self.tool.tool, ToolId::Frame | ToolId::Section) + && let Some(drawn) = self.selection().first().copied() + { + let ds = self.ds_mut()?; + let before = ds.doc.clone(); + take_enclosed(ds.doc_mut(), drawn)?; + crate::normalize(ds.doc_mut(), &before); + } + self.commit_interaction()?; + if !self.prefs.keep_tool { + self.set_tool(ToolId::Move); + } + } + Gesture::Pan { .. } => { + self.tool.overlay.cursor = Cursor::Grab; + if let Some(t) = self.tool.temp_from.take() { + self.tool.tool = t; + } + } + Gesture::Pencil { mut points, mut pressure } => { + self.tool.overlay.pen.clear(); + // The stroke ends where the button came up, even without a move to get there. + if points.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { + points.push(ev.p()); + pressure.push(ev.pressure); + } + self.pencil_finish(&points, &pressure)?; + } + Gesture::Connect { from, start } => self.connect_up(ev, from, start)?, + Gesture::Erase { .. } => self.erase_up()?, + Gesture::Reattach { id, from, start } => self.reattach_up(ev, id, from, start)?, + Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {} + Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {} + Gesture::Vector(_) => self.commit_interaction()?, + } + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/handles.rs b/crates/engine/src/canvas/tools/handles.rs new file mode 100644 index 00000000..fd765f66 --- /dev/null +++ b/crates/engine/src/canvas/tools/handles.rs @@ -0,0 +1,426 @@ +use super::*; + +/// The frame whose name label (see [`ToolState::labels`]) is under `p` on `page`: the topmost +/// one drawn, if it can be selected (still there, on the page, visible and unlocked). +pub fn label_at(doc: &Document, page: NodeId, labels: &[(NodeId, [Point; 4])], p: Point) -> Option { + labels + .iter() + .rev() + .find(|(id, q)| in_quad(q, p) && doc.page_of(*id) == Some(page) && doc.node(*id).is_some_and(|n| n.visible && !n.locked)) + .map(|(id, _)| *id) +} + +/// Whether `p` is inside the convex quadrilateral `q` (either winding; edges included). +pub(super) fn in_quad(q: &[Point; 4], p: Point) -> bool { + let (mut left, mut right) = (false, false); + for (a, b) in q.iter().zip(q.iter().cycle().skip(1)) { + let side = (*b - *a).cross(p - *a); + if !side.is_finite() { + return false; + } + left |= side < 0.0; + right |= side > 0.0; + } + // On one side of every edge (a point can't be on all four edges of a quad with an area). + left != right +} + +/// 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") +} + +/// The fill new text takes to be readable where it is drawn, in `parent`: white on a dark +/// background (None: the model's default, black). The background is the nearest layer from `parent` +/// out whose top visible fill is a mostly opaque solid colour, else the page (#362). For text made +/// in the app: by the Text tool, or by a command that names no fill while the interface is up. +pub(crate) fn readable_text_fills(doc: &Document, parent: NodeId) -> Option> { + let dark = std::iter::once(parent).chain(doc.ancestors(parent)).filter_map(|id| doc.node(id)).find_map(|n| match &n.kind { + NodeKind::Page { background, .. } => Some(background.is_dark()), + _ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| p.solid_color()).filter(|c| c.a >= 0.5).map(|c| c.is_dark()), + }); + dark.unwrap_or(false).then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]) +} + +const ANCHORS: [&str; 9] = ["tl", "t", "tr", "l", "c", "r", "bl", "b", "br"]; + +const HANDLE_PX: f64 = 5.0; + +const ROTATE_PX: f64 = 18.0; + +/// Snap distance in screen pixels. +pub(crate) const SNAP_PX: f64 = 5.0; + +/// Screen pixels the pointer travels after a press before a move or resize starts: a click or a +/// double-click made with an unsteady hand doesn't drag what it lands on. +pub(super) const DRAG_SLOP_PX: f64 = 3.0; + +/// Screen pixels from a path within which the Text on a path tool takes it. +pub(super) const PATH_REACH_PX: f64 = 6.0; + +/// The visible layers under `id` whose box, grown by `reach` (page units), holds `p`, front-most +/// first: what the Text on a path tool measures its distance to. Content a clipping frame or a +/// mask hides at `p` and the operands of boolean groups are left out. +pub(super) fn path_candidates(doc: &Document, id: NodeId, p: Point, reach: f64, depth: usize, out: &mut Vec) { + let Some(n) = doc.node(id) else { return }; + if !n.visible || depth > 512 || matches!(n.kind, NodeKind::BooleanOp { .. }) { + return; + } + if n.clip && n.kind.is_frame_like() && !n.local_rect().contains(doc.world_transform(id).inverse() * p) { + return; + } + for c in n.paint_order().rev() { + let before = out.len(); + path_candidates(doc, c, p, reach, depth + 1, out); + if out.len() > before && figcraft_doc::geometry::masked_out(doc, n, c, p) { + out.truncate(before); + } + } + if n.children.is_empty() && !n.is_mask && doc.world_bounds(id).is_some_and(|b| b.inflate(reach, reach).contains(p)) { + out.push(id); + } +} + +/// Corners of the selection's handle box in page coordinates (tl, tr, br, bl). +pub fn selection_box(doc: &Document, sel: &[NodeId]) -> Option<[Point; 4]> { + let (f, w, h) = selection_frame(doc, sel)?; + Some([f * Point::ZERO, f * Point::new(w, 0.0), f * Point::new(w, h), f * Point::new(0.0, h)]) +} + +/// The selection's handle box: the page transform of its top-left corner (axes along the box) and +/// its size. One layer: its own box; several sharing a rotation: a box turned with them; +/// otherwise the page-aligned bounds. +pub fn selection_frame(doc: &Document, sel: &[NodeId]) -> Option<(Affine, f64, f64)> { + let ids = roots(doc, sel); + if let [id] = ids.as_slice() { + let n = doc.node(*id)?; + return Some((doc.world_transform(*id), n.width, n.height)); + } + let Some(angle) = shared_rotation(doc, &ids) else { + let b = doc.bounds_of(&ids)?; + return Some((Affine::translate((b.x0, b.y0)), b.width(), b.height())); + }; + let rot = Affine::rotate(angle); + let inv = rot.inverse(); + let b = ids + .iter() + .filter_map(|i| Some(figcraft_geom::transform_rect_bbox(inv * doc.world_transform(*i), doc.node(*i)?.local_rect()))) + .reduce(|a, b| a.union(b))?; + Some((rot * Affine::translate((b.x0, b.y0)), b.width(), b.height())) +} + +/// The rotation (radians) every layer in `ids` shares, unless it's a multiple of 90 degrees (the +/// page-aligned box is then the same box). Flipped layers count by their axis, not its direction. +fn shared_rotation(doc: &Document, ids: &[NodeId]) -> Option { + use std::f64::consts::{FRAC_PI_2, PI}; + const EPS: f64 = 1e-4; + let mut angles = ids.iter().map(|i| { + let c = doc.world_transform(*i).as_coeffs(); + c[1].atan2(c[0]) + }); + let a = angles.next()?; + let same = |b: f64| { + let d = (b - a).rem_euclid(PI); + d.min(PI - d) < EPS + }; + let axis_aligned = (a - (a / FRAC_PI_2).round() * FRAC_PI_2).abs() < EPS; + (a.is_finite() && !axis_aligned && angles.all(same)).then_some(a) +} + +/// Whether layer `id` is locked or inside a locked layer: it can be selected in the layers panel +/// but not moved or resized on the canvas. +pub fn locked_in(doc: &Document, id: NodeId) -> bool { + std::iter::once(id).chain(doc.ancestors(id)).any(|a| doc.node(a).is_some_and(|n| n.locked)) +} + +/// Whether layer `t` paints below layer `s`: it holds `s`, or it is behind `s` in the stack of the +/// nearest layer holding both. +pub(super) fn paints_below(doc: &Document, t: NodeId, s: NodeId) -> bool { + let chain = |id: NodeId| { + let mut c: Vec = std::iter::once(id).chain(doc.ancestors(id)).collect(); + c.reverse(); + c + }; + let (ct, cs) = (chain(t), chain(s)); + let shared = ct.iter().zip(&cs).take_while(|(a, b)| a == b).count(); + match (ct.get(shared), cs.get(shared)) { + (None, Some(_)) => true, + (Some(a), Some(b)) => shared.checked_sub(1).and_then(|i| ct.get(i)).and_then(|p| doc.node(*p)).is_some_and(|p| { + let order: Vec = p.paint_order().collect(); + order.iter().position(|c| c == a) < order.iter().position(|c| c == b) + }), + _ => false, + } +} + +/// Which handle (if any) is under `p`. A selection with a locked layer has none. +pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option { + if sel.is_empty() || sel.iter().any(|i| locked_in(doc, *i)) { + return None; + } + // A connector's handles are its ends (see `board::end_handle_at`): no box to resize or turn. + if crate::board::selected_connector_ends(doc, sel).is_some() { + return None; + } + let corners = selection_box(doc, sel)?; + // Lines (zero height) only get end handles. + let is_line = sel.len() == 1 && sel.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Line)); + handle_at_corners(&corners, p, zoom, is_line) +} + +/// Half the side of the square the canvas draws on each corner of the handle box (screen pixels, +/// upright whatever the box's rotation). +const CORNER_SQUARE_PX: f64 = 4.0; + +/// Whether `p` is on a corner square of the selection's handle box as drawn: there the corner +/// resizes, although a corner-radius handle 10 px in reaches that far too. +pub(super) fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool { + let reach = CORNER_SQUARE_PX / zoom.max(1e-6); + matches!(handle_at(doc, sel, p, zoom), Some(Handle::Corner(_))) + && selection_box(doc, sel).is_some_and(|c| c.iter().any(|c| (c.x - p.x).abs() <= reach && (c.y - p.y).abs() <= reach)) +} + +/// A box whose sides are both shorter than this on screen (pixels) would be covered by its +/// handles: the canvas draws just its outline (the handles still answer just outside it). +pub const SMALL_BOX_PX: f64 = 16.0; + +/// The stroke the Pencil draws: its colour and weight (the canvas previews it the same). +pub const PENCIL_STROKE: (figcraft_color::Color, f64) = (figcraft_color::Color::BLACK, 2.0); + +/// The shorter and longer sides, in screen pixels, of the box with `corners` (tl, tr, br, bl). +pub fn box_sides_px(corners: &[Point; 4], zoom: f64) -> (f64, f64) { + let [a, b, _, d] = corners; + let (w, h) = ((*b - *a).hypot() * zoom, (*d - *a).hypot() * zoom); + (w.min(h), w.max(h)) +} + +/// Which handle of the box with `corners` (tl, tr, br, bl in page coordinates) is under `p`; +/// `is_line` keeps only the two end handles. +pub fn handle_at_corners(corners: &[Point; 4], p: Point, zoom: f64, is_line: bool) -> Option { + let tol = HANDLE_PX / zoom.max(1e-6); + // Inside the box, handles reach in at most a quarter of its shorter side, so that a press in + // the middle of a small layer moves it; outside it they reach as far as ever. + let inner = if !is_line && in_quad(corners, p) { box_sides_px(corners, zoom).0 / 4.0 / zoom.max(1e-6) } else { f64::INFINITY }; + let (corner_reach, edge_reach) = ((tol * 1.6).min(inner), tol.min(inner)); + // The nearest corner, then the nearest edge, in reach (a small box's reaches overlap). + let nearest = |hits: &mut dyn Iterator| hits.min_by(|a, b| a.0.total_cmp(&b.0)).map(|(_, h)| h); + let corner = nearest( + &mut corners + .iter() + .enumerate() + .filter(|(i, _)| !(is_line && *i >= 2)) + .map(|(i, c)| ((*c - p).hypot(), Handle::Corner(i as u8))) + .filter(|(d, _)| *d <= corner_reach), + ); + if corner.is_some() { + return corner; + } + if !is_line { + use figcraft_geom::kurbo::ParamCurveNearest; + let edge = nearest(&mut (0..4).filter_map(|i| { + let (a, b) = (corners.get(i)?, corners.get((i + 1) % 4)?); + let near = figcraft_geom::kurbo::Line::new(*a, *b).nearest(p, 0.001); + let d = near.distance_sq.sqrt(); + (d <= edge_reach && near.t > 0.05 && near.t < 0.95).then_some((d, Handle::Edge(i as u8))) + })); + if edge.is_some() { + return edge; + } + } + // Rotation zones: just outside each corner. + let center = Point::new(corners.iter().map(|c| c.x).sum::() / 4.0, corners.iter().map(|c| c.y).sum::() / 4.0); + let rot = ROTATE_PX / zoom.max(1e-6); + for (i, c) in corners.iter().enumerate() { + let d = (*c - p).hypot(); + let outward = (p - *c).dot(*c - center) > 0.0; + if d <= rot && d > tol && outward { + return Some(Handle::Rotate(i as u8)); + } + } + None +} + +pub(super) fn resize_cursor(doc: &Document, sel: &[NodeId], h: Handle) -> Cursor { + selection_box(doc, sel).map_or(Cursor::Default, |c| handle_cursor(&c, h)) +} + +/// The cursor over handle `h` of the box with corners `c`. +pub(crate) fn handle_cursor(c: &[Point; 4], h: Handle) -> Cursor { + let angle = |a: Point, b: Point| (b - a).atan2().to_degrees(); + match h { + Handle::Corner(i) => { + let a = c.get(i as usize).copied().unwrap_or_default(); + let b = c.get((i as usize + 2) % 4).copied().unwrap_or_default(); + // Along the box's own axes at 45°, not the diagonal, so wide or tall boxes still get a + // diagonal cursor. A box with no width or height falls back to the diagonal. + let unit = |v: figcraft_geom::kurbo::Vec2| { + let len = v.hypot(); + (len.is_finite() && len > 1e-9).then(|| v / len) + }; + let next = c.get((i as usize + 1) % 4).copied().unwrap_or_default(); + let prev = c.get((i as usize + 3) % 4).copied().unwrap_or_default(); + match (unit(next - a), unit(prev - a)) { + (Some(u), Some(v)) => Cursor::Resize((u + v).atan2().to_degrees()), + _ => Cursor::Resize(angle(a, b)), + } + } + Handle::Edge(i) => { + let a = c.get(i as usize).copied().unwrap_or_default(); + let b = c.get((i as usize + 1) % 4).copied().unwrap_or_default(); + Cursor::Resize(angle(a, b) + 90.0) + } + Handle::Rotate(i) => { + let centre = Point::new(c.iter().map(|p| p.x).sum::() / 4.0, c.iter().map(|p| p.y).sum::() / 4.0); + Cursor::Rotate(angle(centre, c.get(i as usize).copied().unwrap_or_default())) + } + } +} + +/// The box (x0, y0, x1, y1) a `w0`×`h0` handle box becomes when `handle` is dragged to `q`, all in +/// the box's own axes: `keep` constrains proportions, `from_center` resizes about the centre (the +/// opposite side mirrors the dragged one). Edges that cross over come back reversed (x1 < x0 or +/// y1 < y0: a flip). +pub(crate) fn resized_box(handle: Handle, q: Point, w0: f64, h0: f64, keep: bool, from_center: bool) -> (f64, f64, f64, f64) { + let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, w0, h0); + // The dragged sides: x0 or x1 (`Some(false)` / `Some(true)`), y0 or y1. + let (sx, sy) = match handle { + Handle::Corner(0) => (Some(false), Some(false)), + Handle::Corner(1) => (Some(true), Some(false)), + Handle::Corner(2) => (Some(true), Some(true)), + Handle::Corner(_) => (Some(false), Some(true)), + Handle::Edge(0) => (None, Some(false)), + Handle::Edge(1) => (Some(true), None), + Handle::Edge(2) => (None, Some(true)), + Handle::Edge(_) => (Some(false), None), + Handle::Rotate(_) => (None, None), + }; + match sx { + Some(false) => (x0, x1) = (q.x, if from_center { w0 - q.x } else { w0 }), + Some(true) => (x0, x1) = (if from_center { w0 - q.x } else { 0.0 }, q.x), + None => {} + } + match sy { + Some(false) => (y0, y1) = (q.y, if from_center { h0 - q.y } else { h0 }), + Some(true) => (y0, y1) = (if from_center { h0 - q.y } else { 0.0 }, q.y), + None => {} + } + if keep && w0 > 1e-9 && h0 > 1e-9 && !matches!(handle, Handle::Rotate(_)) { + // One factor for both axes: the dragged axis's (a corner: the one that moved the most), + // about the point that stays put (the opposite handle, or the centre). + let (kx, ky) = ((x1 - x0) / w0, (y1 - y0) / h0); + let k = match (sx, sy) { + (None, _) => ky, + (_, None) => kx, + _ if kx.abs() > ky.abs() => kx, + _ => ky, + }; + let pivot = if from_center { Point::new(w0 / 2.0, h0 / 2.0) } else { handle_point(opposite(handle), w0, h0) }; + (x0, x1) = (pivot.x * (1.0 - k), pivot.x + (w0 - pivot.x) * k); + (y0, y1) = (pivot.y * (1.0 - k), pivot.y + (h0 - pivot.y) * k); + } + (x0, y0, x1, y1) +} + +/// 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)) +} + +/// The readout of a shape handle drag on `id`. +pub(super) fn shape_hint(doc: &Document, id: NodeId, handle: ShapeHandle, zoom: f64) -> Option { + Some(ShapeHint { handle, value: crate::shape_handles::value(doc, id, handle)?, at: crate::shape_handles::handle_at(doc, id, handle, zoom)? }) +} + +#[allow(dead_code)] +fn unused(_: BTreeMap) {} + +/// An end point of a selected open vector within `tol` of page point `p`: (layer, point). +pub(super) fn selected_end_at(doc: &Document, sel: &[NodeId], p: Point, tol: f64) -> Option<(NodeId, usize)> { + sel.iter().find_map(|id| { + let net = crate::vectoredit::network(doc, *id)?; + let w = doc.world_transform(*id); + let near = |v: &usize| net.vertices.get(*v).is_some_and(|x| (w * x.point() - p).hypot() <= tol); + (0..net.vertices.len()).filter(near).find(|v| net.incident(*v).len() == 1).map(|v| (*id, v)) + }) +} diff --git a/crates/engine/src/canvas/tools/keys.rs b/crates/engine/src/canvas/tools/keys.rs new file mode 100644 index 00000000..591f1479 --- /dev/null +++ b/crates/engine/src/canvas/tools/keys.rs @@ -0,0 +1,174 @@ +use super::*; + +impl Session { + /// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point + /// keys, Enter to finish the shape builder). Returns whether the key was used. + pub fn tool_key(&mut self, name: &str, shift: bool, alt: bool, cmd: bool) -> Result { + // Dev Mode edits nothing: keys go to the commands (which it refuses in the app). + if self.tool.dev_mode { + return Ok(false); + } + if self.ds().is_ok_and(|d| d.vector_edit.is_some()) { + return crate::vectoredit::key(self, name, shift, alt, cmd); + } + if !alt && !cmd && self.guide_key(name, shift)? { + return Ok(true); + } + crate::shapebuilder::key(self, name) + } + + /// Keys for a selected ruler guide: Delete removes it, arrows across it nudge it (Shift: the big + /// nudge), Escape deselects it. + fn guide_key(&mut self, name: &str, shift: bool) -> Result { + let Some((owner, index)) = self.ds()?.selected_guide() else { return Ok(false) }; + // Backspace is Delete, `Left` is `ArrowLeft`, and so on. + let name = crate::keymap::normalize(name).map_or(name, |(k, _)| k); + let page = self.page()?; + let at = |mut p: serde_json::Value| { + if owner != page { + p["frame"] = json!(owner); + } + p + }; + let Some(g) = self.doc()?.node(owner).and_then(|n| n.guides().get(index).cloned()) else { return Ok(false) }; + let step = if shift { self.prefs.nudge_big } else { self.prefs.nudge_small }; + let d = match (name, g.vertical) { + ("Delete", _) => { + self.execute("guide.remove", &at(json!({"index": index})))?; + return Ok(true); + } + ("Escape", _) => { + self.execute("guide.select", &json!({}))?; + return Ok(true); + } + ("ArrowLeft", true) | ("ArrowUp", false) => -step, + ("ArrowRight", true) | ("ArrowDown", false) => step, + _ => return Ok(false), + }; + self.execute("guide.move", &at(json!({"index": index, "offset": g.offset + d})))?; + Ok(true) + } + + /// The layer under `p` for a click: the topmost on `page`, or in the focus view the focused + /// frame's. Boolean groups are hit by their results ([`crate::booleans::result_outline`]); in + /// outline mode shapes only on their outlines. + pub fn hit(&self, doc: &Document, page: NodeId, p: Point, tol: f64) -> Option { + let sel = self.selection(); + let result = crate::booleans::result_outline(doc, &sel); + let focus = self.ds().ok().and_then(crate::DocState::focused); + // Outline mode draws no fills: shapes are clicked on their outlines, reaching what's behind. + if let Some(v) = self.ds().ok().map(|d| &d.view).filter(|v| v.outlines) { + // What it draws besides the shapes can be clicked too. + let also = figcraft_doc::geometry::OutlineHits { hidden: v.outline_hidden, bounds: v.outline_bounds }; + return figcraft_doc::geometry::hit_outlines(doc, focus.unwrap_or(page), p, tol, Some(&result), also); + } + match focus { + Some(f) => figcraft_doc::geometry::hit_in(doc, f, p, tol, Some(&result)), + None => doc.hit_test(page, p, tol, Some(&result)), + } + } + + /// Enter with a shape or frame tool (`tool.place`): what a click with it makes, centred on `at` + /// (default the middle of the view) and selected. + pub fn place(&mut self, at: Option) -> Result<()> { + let tool = self.tool.tool; + let refuse = |why: &str| Err(crate::EngineError::Disabled("tool.place".into(), why.into())); + if !tool.draws_shape() { + return refuse("choose a shape or frame tool first"); + } + if self.tool.busy() { + return refuse("finish the drag first"); + } + let v = &self.ds()?.view; + let (c, zoom) = (at.unwrap_or(Point::new(v.cx, v.cy)), v.zoom); + let (w, h) = tool.click_size(); + let ev = |kind| PointerEvent::at(kind, c.x - w / 2.0, c.y - h / 2.0, zoom); + let r = self.pointer(&ev(PointerKind::Down)).and_then(|()| self.pointer(&ev(PointerKind::Up))); + if r.is_err() { + self.tool.gesture = Gesture::None; + let _ = self.cancel_interaction(); + } + r + } + + /// Keyboard Escape: cancel gestures, finish pens, leave text editing, then deselect (in the + /// focus view, once the focused frame or nothing is selected, it ends the focus view). + pub fn escape(&mut self) -> Result<()> { + // Following someone in a live session: Escape stops that first. + if self.live_unfollow() { + return Ok(()); + } + if self.tool.pen.is_some() { + return self.pen_end(); + } + if self.tool.busy() { + self.tool.gesture = Gesture::None; + self.tool.overlay = Overlay::default(); + return self.cancel_interaction(); + } + if self.end_origin_edit() { + return Ok(()); + } + if self.ds()?.text_edit.is_some() { + return textedit::end(self); + } + if self.ds()?.vector_edit.is_some() { + return crate::vectoredit::escape(self); + } + if self.tool.tool != ToolId::Move { + self.set_tool(ToolId::Move); + return Ok(()); + } + let sel = self.selection(); + if let Some(f) = self.ds()?.focused() + && (sel.is_empty() || sel == [f]) + { + self.ds_mut()?.focus = None; + return Ok(()); + } + // Escape selects the parent, then nothing. + let doc = self.doc()?; + let parents: Vec = + sel.iter().filter_map(|i| doc.node(*i).and_then(|n| n.parent)).filter(|p| !doc.node(*p).is_some_and(Node::is_page)).collect(); + if !parents.is_empty() { + let mut uniq = Vec::new(); + for p in parents { + if !uniq.contains(&p) { + uniq.push(p); + } + } + self.set_selection(uniq); + } else { + self.set_selection(vec![]); + } + Ok(()) + } + + /// Whether the Move tool shows arc and shape handles now (not while another gesture runs, nor + /// in vector or text edit mode). + pub(super) fn arc_handles_shown(&self) -> bool { + let arc_drag = matches!(self.tool.gesture, Gesture::Arc { .. } | Gesture::Shape { .. }); + self.tool.tool == ToolId::Move + && !self.tool.dev_mode + && (arc_drag || !self.tool.busy()) + && self.ds().is_ok_and(|ds| ds.vector_edit.is_none() && ds.text_edit.is_none()) + } + + /// The selected ellipse's arc handles as the canvas shows them at the current zoom. + pub fn arc_handles(&self) -> Option { + let ds = self.ds().ok()?; + self.arc_handles_shown().then(|| crate::arc_handles::handles(&ds.doc, &ds.selection, ds.view.zoom)).flatten() + } + + /// The selected shape's corner radius and star ratio handles as the canvas shows them now. + pub fn shape_handles(&self) -> Vec<(ShapeHandle, [f64; 2])> { + let Ok(ds) = self.ds() else { return vec![] }; + if self.arc_handles_shown() { crate::shape_handles::handles(&ds.doc, &ds.selection, ds.view.zoom) } else { vec![] } + } + + /// Snapshot of tool state for the UI / inspect. + pub fn tool_info(&self) -> serde_json::Value { + let shapes: Vec = self.shape_handles().into_iter().map(|(h, at)| json!({"handle": h, "at": at})).collect(); + json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes}) + } +} diff --git a/crates/engine/src/canvas/tools/pen.rs b/crates/engine/src/canvas/tools/pen.rs new file mode 100644 index 00000000..319c11b7 --- /dev/null +++ b/crates/engine/src/canvas/tools/pen.rs @@ -0,0 +1,276 @@ +use super::handles::{PENCIL_STROKE, selected_end_at}; +use super::*; + +/// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most +/// 32 points, medium pressure (0.5) at the stroke weight, from a tenth to twice it. `None` when no +/// sample has pressure. +pub(crate) fn pressure_profile(points: &[Point], pressure: &[Option]) -> Option> { + if pressure.iter().all(Option::is_none) { + return None; + } + let mut lengths = vec![0.0]; + for w in points.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + lengths.push(lengths.last().copied().unwrap_or(0.0) + (*b - *a).hypot()); + } + let total = lengths.last().copied().unwrap_or(0.0); + if !(total.is_finite() && total > 0.0) { + return None; + } + let at = |i: usize| f64::from(pressure.get(i).copied().flatten().filter(|p| p.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0)); + let n = points.len().clamp(2, 32); + let profile = (0..n).map(|k| { + let t = k as f64 / (n - 1) as f64; + let s = t * total; + let i = lengths.partition_point(|l| *l < s).min(lengths.len().saturating_sub(1)); + let p = match (i.checked_sub(1).and_then(|j| lengths.get(j)), lengths.get(i)) { + (Some(a), Some(b)) if b > a => at(i - 1) + (at(i) - at(i - 1)) * ((s - a) / (b - a)), + _ => at(i), + }; + [t, (p / 0.5).clamp(0.1, 2.0)] + }); + Some(profile.collect()) +} + +/// A frame or section drawn around layers takes those of its siblings that lie fully inside it +/// (visible and unlocked ones, in their order; a frame never takes a section). Not in auto layout, +/// where the new frame is one more item of the flow. +pub(super) fn take_enclosed(doc: &mut Document, drawn: NodeId) -> Result<()> { + let Some(n) = doc.node(drawn) else { return Ok(()) }; + let section = matches!(n.kind, NodeKind::Section { .. }); + let (Some(parent), Some(area)) = (n.parent.and_then(|p| doc.node(p)), doc.world_bounds(drawn)) else { return Ok(()) }; + if parent.layout.is_some() { + return Ok(()); + } + let inside: Vec = parent + .children + .iter() + .copied() + .filter(|c| *c != drawn && doc.node(*c).is_some_and(|k| k.visible && !k.locked && (section || !matches!(k.kind, NodeKind::Section { .. })))) + .filter(|c| doc.world_bounds(*c).is_some_and(|b| area.contains(b.origin()) && area.contains(Point::new(b.x1, b.y1)))) + .collect(); + for c in inside { + doc.reparent(c, drawn, None)?; + } + Ok(()) +} + +impl Session { + /// The pen tool's snapped position for `ev`: its path's vector (or, before it has one, its first + /// point) counts among the targets. + pub(super) fn snap_pen_tool(&mut self, ev: &PointerEvent) -> Result<(Point, Vec)> { + let (id, extra) = match &self.tool.pen { + Some(pen) if pen.id.is_some() => (pen.id, Vec::new()), + Some(pen) => (None, pen.points.iter().map(|(p, _, _)| *p).collect()), + None => (None, Vec::new()), + }; + self.snap_pen(ev, id, &extra) + } + + pub(super) fn pen_down(&mut self, ev: &PointerEvent) -> Result<()> { + self.tool.overlay.guides.clear(); + // The segment being placed shows as itself while the press lasts. + self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; + let p = if ev.shift { ev.p() } else { self.snap_pen_tool(ev)?.0 }; + let (doc, page) = { + let ds = self.ds()?; + (ds.doc.clone(), ds.page) + }; + // With an open path selected, a click on one of its end points continues it in edit mode. + if self.tool.pen.is_none() + && let Some((id, v)) = selected_end_at(&doc, &self.selection(), ev.p(), 8.0 / ev.zoom.max(1e-6)) + { + return self.pen_edit(id, Some(v)); + } + let pen = self.tool.pen.get_or_insert_with(PenPath::default); + // Clicking the first anchor closes the path. + if pen.points.len() > 2 + && let Some((first, _, _)) = pen.points.first() + && (*first - p).hypot() * ev.zoom < 8.0 + { + pen.closed = true; + self.pen_update()?; + return self.pen_finish(); + } + // Clicking another of its own anchors (not the last) connects there and ends the path. + let near = |q: &Point| (*q - p).hypot() * ev.zoom < 8.0; + if let Some(k) = (1..pen.points.len().saturating_sub(1)).find(|k| pen.points.get(*k).is_some_and(|(q, _, _)| near(q))) { + pen.join = Some(k); + self.pen_update()?; + return self.pen_finish(); + } + if pen.parent.is_none() { + pen.parent = Some(frame_at(&doc, page, p, &[]).unwrap_or(page)); + } + let mut p = p; + if ev.shift + && let Some((last, _, _)) = pen.points.last() + { + p = at_45_degrees(*last, p); + } + pen.points.push((p, Vec2::ZERO, Vec2::ZERO)); + self.tool.gesture = Gesture::PenDrag { anchor: p }; + self.pen_update() + } + + /// Rebuild the pen's vector node from its anchors. + pub(super) fn pen_update(&mut self) -> Result<()> { + let Some(pen) = self.tool.pen.clone() else { return Ok(()) }; + self.tool.overlay.pen = pen.points.iter().map(|(p, _, _)| [p.x, p.y]).collect(); + self.tool.overlay.pen_handles = pen_handles(&pen.points); + if pen.points.len() < 2 { + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + return Ok(()); + } + let page = self.page()?; + let parent = pen.parent.unwrap_or(page); + if pen.id.is_none() { + self.begin_interaction("Pen")?; + } + let ds = self.ds_mut()?; + let doc = ds.doc_mut(); + let pw = doc.world_transform(parent); + let inv = pw.inverse(); + // Anchors and handles into the parent's space. + let pts: Vec<(Point, Point, Point)> = pen.points.iter().map(|(p, i, o)| (inv * *p, inv * (*p + *i), inv * (*p + *o))).collect(); + let mut path = figcraft_geom::BezPath::new(); + for (k, (p, _, _)) in pts.iter().enumerate() { + if k == 0 { + path.move_to(*p); + } else if let (Some(prev), Some(cur)) = (pts.get(k - 1), pts.get(k)) { + path.curve_to(prev.2, cur.1, *p); + } + } + if pen.closed + && let (Some(last), Some(first)) = (pts.last(), pts.first()) + { + path.curve_to(last.2, first.1, first.0); + path.close_path(); + } + use figcraft_geom::Shape; + let bb = path.bounding_box(); + path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); + let mut net = VectorNetwork::from_path(&path); + // Straight segments stay straight. + for s in &mut net.segments { + if (s.ta.0.abs() + s.ta.1.abs()) < 1e-9 && (s.tb.0.abs() + s.tb.1.abs()) < 1e-9 { + s.ta = (0.0, 0.0); + s.tb = (0.0, 0.0); + } + } + if !pen.closed { + net.regions.clear(); + } + // Anchor k is vertex k of the open path. + if let (Some(k), Some((last, _, out))) = (pen.join, pts.last()) { + let from = pts.len() - 1; + crate::vectoredit::ops::connect(&mut net, from, k, (out.x - last.x, out.y - last.y), (0.0, 0.0)); + } + let id = match pen.id { + Some(id) if doc.contains(id) => id, + _ => { + let id = doc.alloc_id(); + let mut n = Node::new(id, NodeKind::Vector { network: VectorNetwork::default() }, "Vector", 0.0, 0.0); + n.fills.clear(); + n.strokes = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]; + n.stroke.align = StrokeAlign::Center; + doc.insert(n, parent, None)?; + id + } + }; + let n = doc.get_mut(id)?; + n.kind = NodeKind::Vector { network: net }; + n.width = bb.width(); + n.height = bb.height(); + n.transform = Affine::translate((bb.x0, bb.y0)); + if pen.closed && n.fills.is_empty() { + n.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)]; + } + ds.selection = vec![id]; + ds.revision += 1; + if let Some(p) = self.tool.pen.as_mut() { + p.id = Some(id); + } + Ok(()) + } + + /// Edit `id` as a vector with the pen, going on from point `anchor` (or, without one, starting + /// a new path in the same layer at the next click). + fn pen_edit(&mut self, id: NodeId, anchor: Option) -> Result<()> { + self.set_tool(ToolId::Move); + crate::vectoredit::begin(self, id)?; + crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?; + crate::vectoredit::set_state(self, |e| { + e.anchor = anchor; + e.vertices = anchor.into_iter().collect(); + }) + } + + /// Enter/Escape while the pen draws: the path ends but its layer stays in edit mode with the + /// pen, so the next path joins the same vector (another Escape leaves edit mode). + pub fn pen_end(&mut self) -> Result<()> { + let id = self.tool.pen.as_ref().and_then(|p| p.id); + self.pen_finish()?; + match id { + Some(id) if self.doc()?.contains(id) => self.pen_edit(id, None), + _ => Ok(()), + } + } + + /// Finish the pen path (and keep the pen tool: double-click, Delete, a tool switch). + pub fn pen_finish(&mut self) -> Result<()> { + let pen = self.tool.pen.take(); + self.tool.overlay.pen.clear(); + self.tool.overlay.pen_handles.clear(); + self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; + self.tool.gesture = Gesture::None; + if pen.as_ref().is_some_and(|p| p.id.is_some()) { + self.commit_interaction()?; + } + Ok(()) + } + + pub(super) fn pencil_finish(&mut self, points: &[Point], pressure: &[Option]) -> Result<()> { + if points.len() < 2 { + return Ok(()); + } + // Smooth: fit a curve through the samples. + let mut poly = figcraft_geom::BezPath::new(); + for (k, p) in points.iter().enumerate() { + if k == 0 { + poly.move_to(*p); + } else { + poly.line_to(*p); + } + } + let fitted = figcraft_geom::kurbo::simplify::simplify_bezpath(poly.iter(), 0.75, &Default::default()); + let d = fitted.to_svg(); + if matches!(self.tool.tool, ToolId::Marker | ToolId::Highlighter) { + return self.ink_finish(&d); + } + let page = self.page()?; + let parent = frame_at(self.doc()?, page, points.first().copied().unwrap_or_default(), &[]).unwrap_or(page); + let pw = self.doc()?.world_transform(parent); + let mut local = fitted.clone(); + local.apply_affine(pw.inverse()); + let _ = d; + let (color, weight) = PENCIL_STROKE; + let mut props = json!({"fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"}); + // Pen pressure becomes the stroke's width profile (brushes follow it too). + if let Some(points) = pressure_profile(points, pressure) { + props["widthProfile"] = json!(points); + } + if let Some(b) = &self.tool.pencil_brush { + props["brush"] = serde_json::to_value(b).unwrap_or_default(); + } + self.execute( + "node.create", + &json!({"type": "vector", "path": local.to_svg(), "closed": false, "parent": parent, "name": "Vector", "props": props}), + )?; + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/pointer.rs b/crates/engine/src/canvas/tools/pointer.rs new file mode 100644 index 00000000..985e3326 --- /dev/null +++ b/crates/engine/src/canvas/tools/pointer.rs @@ -0,0 +1,292 @@ +use super::handles::{handle_at, label_at, locked_in, on_corner_square, resize_cursor, selected_end_at}; +use super::*; + +impl Session { + /// The pointer left the canvas: what hovering there showed goes (the distances Alt or Dev Mode + /// measured and the box measured to, a vector region's highlight), unless a gesture holds it. + pub fn pointer_left(&mut self) { + if !self.tool.busy() { + self.tool.overlay.guides.clear(); + self.tool.overlay.measure_box = None; + } + self.vector_pointer_left(); + } + + /// What a marquee starting at page point `p` selects inside: the top-level frame it starts in + /// (its children), or the focus view's frame. + pub(crate) fn marquee_within(&self, p: Point) -> Result> { + let (doc, page) = (self.doc()?, self.page()?); + let within = frame_at(doc, page, p, &[]).filter(|f| doc.node(*f).is_some_and(|n| n.parent == Some(page))); + let within = within.filter(|f| doc.world_bounds(*f).is_some_and(|b| b.contains(p))); + Ok(self.ds()?.focused().or(within)) + } + + /// The selection a marquee over page rect `r` makes from `base` (what was selected before): + /// the layers it touches (`deep`: nested ones too); `additive` (Shift) toggles them. + pub(crate) fn marquee_selection(&self, r: Rect, within: Option, deep: bool, base: &[NodeId], additive: bool) -> Result> { + let (doc, page) = (self.doc()?, self.page()?); + let mut hits = + if deep { figcraft_doc::geometry::marquee_deep(doc, page, r) } else { figcraft_doc::geometry::marquee_select(doc, page, r, within) }; + if let Some(f) = self.ds()?.focused() { + hits.retain(|h| *h != f && doc.is_ancestor(f, *h)); + } + // A marquee over a frame from outside selects the frame only when it covers it fully. + if within.is_none() && !deep { + hits.retain(|h| { + let full = doc.world_bounds(*h).is_some_and(|b| r.contains(b.origin()) && r.contains(Point::new(b.x1, b.y1))); + full || !doc.node(*h).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) + }); + } + // Shift toggles: what was selected and is touched again comes out. + let mut sel: Vec = base.iter().copied().filter(|b| !(additive && hits.contains(b))).collect(); + for h in hits { + if !base.contains(&h) && !sel.contains(&h) { + sel.push(h); + } + } + Ok(sel) + } + + pub fn set_tool(&mut self, t: ToolId) { + if self.tool.tool == ToolId::Pen && t != ToolId::Pen { + let _ = self.pen_finish(); + } + if !matches!(t, ToolId::Move | ToolId::Scale | ToolId::Hand) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) { + let _ = crate::vectoredit::end(self); + } + self.tool.tool = t; + self.tool.gesture = Gesture::None; + self.tool.pick_only = false; + // The tool's cursor shows before the pointer moves. + self.tool.overlay = + Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, cursor: t.cursor(), ..Overlay::default() }; + self.tool.shape_builder = None; + if t == ToolId::ShapeBuilder { + // Regions show straight away, before the pointer moves. + let sel = self.selection(); + let _ = crate::shapebuilder::arrangement(self, &sel); + } + if let Ok(ds) = self.ds_mut() { + if !matches!(t, ToolId::Text | ToolId::TextOnPath | ToolId::Move) { + ds.text_edit = None; + } + ds.revision += 1; + } + } + + /// The tool pointer events go to: in Dev Mode every tool but the hand and comments selects. + fn acting_tool(&self) -> ToolId { + match self.tool.tool { + t if self.tool.dev_mode && !matches!(t, ToolId::Hand | ToolId::Comment) => ToolId::Move, + t => t, + } + } + + /// Drop the gesture under way (a drag, a resize, a shape being drawn), restoring the document + /// as Escape does; the UI calls it when Dev Mode starts, where nothing may move. + pub fn drop_gesture(&mut self) -> Result<()> { + self.tool.gesture = Gesture::None; + self.cancel_interaction() + } + + /// Feed one pointer event to the current tool. + pub fn pointer(&mut self, ev: &PointerEvent) -> Result<()> { + // A held button sends the same move every frame: running the gesture again would only + // rebuild the same preview (a new document, and a canvas render, every frame). + if ev.kind != PointerKind::Move { + self.tool.last_move = None; + } else if self.tool.busy() { + if self.tool.last_move.as_ref() == Some(ev) { + return Ok(()); + } + self.tool.last_move = Some(ev.clone()); + } + let tool = self.acting_tool(); + let edits = matches!(tool, ToolId::Move | ToolId::Scale) && !self.tool.dev_mode; + if edits && self.origin_pointer(ev)? { + return Ok(()); + } + if edits && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? { + return Ok(()); + } + if tool == ToolId::ShapeBuilder { + return self.shape_builder_pointer(ev); + } + match ev.kind { + PointerKind::Hover => self.hover(ev), + PointerKind::Down => match tool { + ToolId::Move | ToolId::Scale => { + // While a board's vote runs, a click on an object votes. + if tool == ToolId::Move && !self.tool.dev_mode && self.vote_down(ev)? { + return Ok(()); + } + // A click on the canvas deselects a ruler guide (the UI selects guides itself). + self.ds_mut()?.guide = None; + self.move_down(ev) + } + ToolId::Hand => { + let v = &self.ds()?.view; + self.tool.gesture = Gesture::Pan { sx: ev.sx, sy: ev.sy, cx: v.cx, cy: v.cy }; + self.tool.overlay.cursor = Cursor::Grabbing; + Ok(()) + } + ToolId::Text => self.text_down(ev), + ToolId::TextOnPath => self.text_on_path_down(ev), + ToolId::Pen => self.pen_down(ev), + ToolId::Pencil => { + self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; + Ok(()) + } + ToolId::Comment => { + self.request_ui("comment.new", json!({"x": ev.x, "y": ev.y})); + Ok(()) + } + ToolId::Eyedropper => self.eyedrop(ev), + t if t.on_boards() => self.board_down(ev), + t if t.draws_shape() => self.draw_down(ev), + _ => Ok(()), + }, + PointerKind::Move => self.drag(ev), + PointerKind::Up => self.up(ev), + PointerKind::DoubleClick => self.double_click(ev), + } + } + + /// The rotation origin handle while it shows: a press drags it, a double-click puts it back + /// at the centre. True when the event was the handle's. + fn origin_pointer(&mut self, ev: &PointerEvent) -> Result { + let dragging = matches!(self.tool.gesture, Gesture::Origin); + if !dragging && !self.ds()?.on_origin_handle(ev.p(), ev.zoom) { + return Ok(false); + } + match ev.kind { + PointerKind::Down if !dragging => self.tool.gesture = Gesture::Origin, + PointerKind::Move if dragging => self.drag_origin_to(ev.p(), ev.zoom)?, + PointerKind::Up if dragging => self.tool.gesture = Gesture::None, + PointerKind::DoubleClick => self.set_rotation_origin(None)?, + PointerKind::Hover => {} + _ => return Ok(false), + } + self.tool.overlay.cursor = Cursor::Move; + Ok(true) + } + + pub(super) fn hover(&mut self, ev: &PointerEvent) -> Result<()> { + let tool = self.acting_tool(); + let ds = self.ds()?; + let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + let mut cursor = tool.cursor(); + // The text being edited, and with the Text tool any text, takes the I-beam: a click there + // puts the caret. + let on_edited = ds + .text_edit + .as_ref() + .is_some_and(|te| doc.world_bounds(te.id).is_some_and(|b| b.inflate(ev.tol() * 4.0, ev.tol() * 4.0).contains(ev.p()))); + let on_text = + |s: &Self| s.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|l| doc.node(l)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))); + if tool == ToolId::Text && (on_edited || on_text(self)) { + cursor = Cursor::Text; + } + if tool == ToolId::TextOnPath && (on_edited || self.path_at(&doc, page, ev).is_some()) { + cursor = Cursor::Text; + } + let mut hover = None; + let mut guides = Vec::new(); + let mut measure_box = None; + let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None }; + self.tool.overlay.arc_hover = arc_hover; + let shape_hover = if self.arc_handles_shown() && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) { + crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) + } else { + None + }; + self.tool.overlay.shape_hover = shape_hover; + if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited { + cursor = Cursor::Text; + } else if matches!(tool, ToolId::Move | ToolId::Scale) && arc_hover.is_none() && shape_hover.is_none() { + if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| !self.tool.dev_mode) { + cursor = resize_cursor(&doc, &sel, h); + } else { + let leaf = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); + if leaf.and_then(|l| crate::widgets::target_at(&doc, l, ev.p())).is_some() { + cursor = Cursor::Pointer; + } + hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))); + // Alt over a selected layer: a drag from here duplicates it. + if ev.alt && !self.tool.dev_mode && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) { + cursor = Cursor::Copy; + } + if ev.alt || self.tool.dev_mode { + 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]); + } + } + } + } + if tool == ToolId::Move && !self.tool.dev_mode && crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom).is_some() { + cursor = Cursor::Grab; + } + 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. + let (to, g) = if ev.shift { (ev.p(), Vec::new()) } else { self.snap_pen_tool(ev)? }; + guides = g; + self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom)); + if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { + // With Shift the band ends where a click puts the point (#970). + let to = if ev.shift { at_45_degrees(*last, to) } else { to }; + self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]); + self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]); + } + } + self.tool.overlay.guides = guides; + let ds = self.ds_mut()?; + if ds.hover != hover { + ds.hover = hover; + ds.revision += 1; + } + Ok(()) + } + + /// What a click with the Pen tool at `p` (where it snaps) would do: close the path on its first + /// point, connect to another of its points, go on from a selected open path's end, or add a + /// new point (the same tests as [`Self::pen_down`]). + fn pen_cursor(&self, doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> PenCursor { + let near = |q: &Point| (*q - p).hypot() * zoom < 8.0; + let on_point = match &self.tool.pen { + None => selected_end_at(doc, sel, p, 8.0 / zoom.max(1e-6)).is_some(), + Some(pen) => { + let n = pen.points.len(); + (n > 2 && pen.points.first().is_some_and(|(q, _, _)| near(q))) + || (1..n.saturating_sub(1)).any(|k| pen.points.get(k).is_some_and(|(q, _, _)| near(q))) + } + }; + if on_point { PenCursor::Close } else { PenCursor::New } + } + + /// Whether what the gesture did to `ids` so far is an edit only a main component can make + /// (`moved`: their place among the layers counts too): it is undone when the gesture ends. + pub(super) fn refused(&self, ids: &[NodeId], moved: bool) -> bool { + let Ok(ds) = self.ds() else { return false }; + ds.interaction.as_ref().is_some_and(|it| { + ids.iter().any(|m| { + (moved && !crate::components::move_allowed(&it.doc, &ds.doc, *m)) + || crate::components::refused_override(&it.doc, &ds.doc, *m).is_some() + }) + }) + } +}