mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 12:56:47 -06:00
- Compare changes: a ready-for-dev frame or section keeps a snapshot of itself from just before
its first change (Document::dev_snapshots, saved only while changed; dropped on resolve, re-mark,
other status or delete). `dev.compare {id?, show?}` diffs it layer by layer: added/removed
layers, and property changes formatted like Inspect (`width 120 → 136`, `fill #FF0000 →
#E03030`, text, parent, order, component, auto layout, typography, effects). The compare view
shows before / now side by side or overlaid with a slider, changed layers outlined, the list of
changes and Resolve change.
- Focus view: `dev.focus {id? | direction}` / `dev.unfocus`: one frame drawn and hit alone
(render Options::solo), a bar with name, status, Compare changes, previous / next ready frame and
exit; Escape leaves.
- Annotation categories: defaults plus the file's own with colours (`annotation.categories`,
`addCategory`, `editCategory`, `removeCategory`), `category` on annotations, coloured callouts,
a category filter and editor in the Inspect panel; stored backwards-compatibly.
- Shared widgets::icon_button / canvas_banner / dropdown_lead; has_status moved to the engine.
- Tests: devcompare_tests, devfocus_tests, annotation_categories_tests, doc handoff_tests, render
solo_tests, UI dev_mode_tests.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1848 lines
77 KiB
Rust
1848 lines
77 KiB
Rust
//! Pointer tools. The UI (or an agent through the control channel) feeds [`PointerEvent`]s in page
|
||
//! coordinates; tools turn gestures into document edits through interactions (one undo step per
|
||
//! gesture) and publish overlay hints (marquee, guides, size label, cursor) for the canvas.
|
||
|
||
use std::collections::BTreeMap;
|
||
|
||
use figcraft_doc::geometry::select_target;
|
||
use figcraft_doc::*;
|
||
use figcraft_geom::{Affine, Point, Rect, Vec2};
|
||
use serde::Serialize;
|
||
use serde_json::json;
|
||
|
||
use crate::arc_handles::{ArcHandle, ArcHint};
|
||
use crate::cmd::arrange::move_world;
|
||
use crate::cmd::create::frame_at;
|
||
use crate::cmd::edit::roots;
|
||
use crate::snap::{Guide, Targets};
|
||
use crate::{Result, Session, textedit};
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Default)]
|
||
pub enum ToolId {
|
||
#[default]
|
||
Move,
|
||
Scale,
|
||
Hand,
|
||
Frame,
|
||
Section,
|
||
Slice,
|
||
Rectangle,
|
||
Line,
|
||
Arrow,
|
||
Ellipse,
|
||
Polygon,
|
||
Star,
|
||
Pen,
|
||
Pencil,
|
||
Text,
|
||
Comment,
|
||
Eyedropper,
|
||
ShapeBuilder,
|
||
}
|
||
|
||
impl ToolId {
|
||
pub const ALL: [ToolId; 18] = [
|
||
ToolId::Move,
|
||
ToolId::Scale,
|
||
ToolId::Hand,
|
||
ToolId::Frame,
|
||
ToolId::Section,
|
||
ToolId::Slice,
|
||
ToolId::Rectangle,
|
||
ToolId::Line,
|
||
ToolId::Arrow,
|
||
ToolId::Ellipse,
|
||
ToolId::Polygon,
|
||
ToolId::Star,
|
||
ToolId::Pen,
|
||
ToolId::Pencil,
|
||
ToolId::Text,
|
||
ToolId::Comment,
|
||
ToolId::Eyedropper,
|
||
ToolId::ShapeBuilder,
|
||
];
|
||
pub fn id(self) -> &'static str {
|
||
match self {
|
||
ToolId::Move => "move",
|
||
ToolId::Scale => "scale",
|
||
ToolId::Hand => "hand",
|
||
ToolId::Frame => "frame",
|
||
ToolId::Section => "section",
|
||
ToolId::Slice => "slice",
|
||
ToolId::Rectangle => "rectangle",
|
||
ToolId::Line => "line",
|
||
ToolId::Arrow => "arrow",
|
||
ToolId::Ellipse => "ellipse",
|
||
ToolId::Polygon => "polygon",
|
||
ToolId::Star => "star",
|
||
ToolId::Pen => "pen",
|
||
ToolId::Pencil => "pencil",
|
||
ToolId::Text => "text",
|
||
ToolId::Comment => "comment",
|
||
ToolId::Eyedropper => "eyedropper",
|
||
ToolId::ShapeBuilder => "shapeBuilder",
|
||
}
|
||
}
|
||
pub fn from_id(s: &str) -> Option<ToolId> {
|
||
ToolId::ALL.into_iter().find(|t| t.id().eq_ignore_ascii_case(s))
|
||
}
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
ToolId::Move => "Move",
|
||
ToolId::Scale => "Scale",
|
||
ToolId::Hand => "Hand tool",
|
||
ToolId::Frame => "Frame",
|
||
ToolId::Section => "Section",
|
||
ToolId::Slice => "Slice",
|
||
ToolId::Rectangle => "Rectangle",
|
||
ToolId::Line => "Line",
|
||
ToolId::Arrow => "Arrow",
|
||
ToolId::Ellipse => "Ellipse",
|
||
ToolId::Polygon => "Polygon",
|
||
ToolId::Star => "Star",
|
||
ToolId::Pen => "Pen",
|
||
ToolId::Pencil => "Pencil",
|
||
ToolId::Text => "Text",
|
||
ToolId::Comment => "Comment",
|
||
ToolId::Eyedropper => "Pick color",
|
||
ToolId::ShapeBuilder => "Shape builder",
|
||
}
|
||
}
|
||
/// The tool's key (from [`crate::keymap`]; empty when it has none).
|
||
pub fn shortcut(self) -> &'static str {
|
||
let id = self.id();
|
||
crate::keymap::KEYS
|
||
.iter()
|
||
.find(|b| if self == ToolId::Eyedropper { b.id == "edit.pickColor" } else { b.id.strip_prefix("tool.") == Some(id) })
|
||
.and_then(|b| b.keys(crate::keymap::Platform::current()).first().copied())
|
||
.unwrap_or("")
|
||
}
|
||
|
||
fn draws_shape(self) -> bool {
|
||
matches!(
|
||
self,
|
||
ToolId::Frame
|
||
| ToolId::Section
|
||
| ToolId::Slice
|
||
| ToolId::Rectangle
|
||
| ToolId::Ellipse
|
||
| ToolId::Polygon
|
||
| ToolId::Star
|
||
| ToolId::Line
|
||
| ToolId::Arrow
|
||
)
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||
pub enum PointerKind {
|
||
Down,
|
||
Move,
|
||
Up,
|
||
/// Pointer moved without a button.
|
||
Hover,
|
||
DoubleClick,
|
||
}
|
||
|
||
/// A pointer event in page coordinates (and screen coordinates for panning).
|
||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||
pub struct PointerEvent {
|
||
pub kind: PointerKind,
|
||
pub x: f64,
|
||
pub y: f64,
|
||
/// Screen position (logical pixels).
|
||
pub sx: f64,
|
||
pub sy: f64,
|
||
pub shift: bool,
|
||
pub alt: bool,
|
||
/// Cmd on macOS, Ctrl elsewhere.
|
||
pub cmd: bool,
|
||
pub ctrl: bool,
|
||
/// Screen pixels per page unit.
|
||
pub zoom: f64,
|
||
}
|
||
|
||
impl PointerEvent {
|
||
pub fn at(kind: PointerKind, x: f64, y: f64, zoom: f64) -> Self {
|
||
Self { kind, x, y, sx: x * zoom, sy: y * zoom, shift: false, alt: false, cmd: false, ctrl: false, zoom }
|
||
}
|
||
pub(crate) fn p(&self) -> Point {
|
||
Point::new(self.x, self.y)
|
||
}
|
||
fn tol(&self) -> f64 {
|
||
1.0 / self.zoom.max(1e-6)
|
||
}
|
||
}
|
||
|
||
/// Cursor hint for the UI.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Default)]
|
||
pub enum Cursor {
|
||
#[default]
|
||
Default,
|
||
Crosshair,
|
||
Move,
|
||
/// Resize along an angle (degrees, 0 = horizontal).
|
||
Resize(f64),
|
||
Rotate,
|
||
Grab,
|
||
Grabbing,
|
||
Text,
|
||
Pen,
|
||
Copy,
|
||
}
|
||
|
||
/// Selection handle kinds: corners (0 tl, 1 tr, 2 br, 3 bl), edges (0 t, 1 r, 2 b, 3 l) and the
|
||
/// rotation zones outside the corners.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||
pub enum Handle {
|
||
Corner(u8),
|
||
Edge(u8),
|
||
Rotate(u8),
|
||
}
|
||
|
||
/// What the canvas should draw on top of the scene.
|
||
#[derive(Clone, Debug, Default, Serialize)]
|
||
pub struct Overlay {
|
||
pub marquee: Option<[f64; 4]>,
|
||
pub guides: Vec<Guide>,
|
||
pub cursor: Cursor,
|
||
/// Pen tool: the in-progress path's anchor points and the rubber band to the pointer.
|
||
pub pen: Vec<[f64; 2]>,
|
||
pub rubber: Option<[f64; 4]>,
|
||
/// Frame/section the pointer is drawing or dropping into (highlighted).
|
||
pub drop_target: Option<NodeId>,
|
||
/// Auto layout insertion indicator (x0, y0, x1, y1).
|
||
pub insert_line: Option<[f64; 4]>,
|
||
/// The instance an Alt-drop of a component from the Assets panel would swap (highlighted).
|
||
pub swap_target: Option<NodeId>,
|
||
/// Auto layout grid cell a drag would drop into (x0, y0, x1, y1).
|
||
pub drop_cell: Option<[f64; 4]>,
|
||
/// Vector edit mode: what the pointer is over, the lasso being drawn and the closed area the
|
||
/// paint bucket would fill (page-space polygons).
|
||
pub vector_hover: Option<crate::vectoredit::VHit>,
|
||
/// Vector edit mode, Variable width tool: the width point, handle or path under the pointer.
|
||
pub width_hover: Option<crate::vectoredit::WHit>,
|
||
pub lasso: Vec<[f64; 2]>,
|
||
/// The eraser brush under the pointer (x, y, radius in page units).
|
||
pub brush: Option<[f64; 3]>,
|
||
pub face: Vec<Vec<[f64; 2]>>,
|
||
/// Shape builder: the regions under the pointer or collected by the drag (indexes into
|
||
/// [`crate::shapebuilder::current`]) and whether releasing removes them.
|
||
pub builder_regions: Vec<usize>,
|
||
pub builder_remove: bool,
|
||
/// Measuring (Alt): the box of the layer or frame measured to (x0, y0, x1, y1), outlined.
|
||
pub measure_box: Option<[f64; 4]>,
|
||
/// Scale tool drag: the factor so far and the point that stays put.
|
||
pub scale: Option<ScaleHint>,
|
||
/// The selected ellipse's arc handle under the pointer, and the readout while one is dragged
|
||
/// (see [`crate::arc_handles`]).
|
||
pub arc_hover: Option<ArcHandle>,
|
||
pub arc: Option<ArcHint>,
|
||
}
|
||
|
||
/// The Scale tool's live readout while dragging a handle.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||
pub struct ScaleHint {
|
||
pub factor: f64,
|
||
pub pivot: [f64; 2],
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct ResizeStart {
|
||
/// Node id → (world transform, w, h) at the start.
|
||
nodes: Vec<(NodeId, Affine, f64, f64)>,
|
||
/// The handle box: page transform of its top-left corner (axes along the box) and its size.
|
||
frame: (Affine, f64, f64),
|
||
/// One layer (resized in place) rather than several (mapped from the old box to the new).
|
||
single: bool,
|
||
handle: Handle,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default)]
|
||
pub(crate) enum Gesture {
|
||
#[default]
|
||
None,
|
||
Marquee {
|
||
start: Point,
|
||
/// Shift: toggle what it touches in the selection it started from (`base`).
|
||
additive: bool,
|
||
base: Vec<NodeId>,
|
||
within: Option<NodeId>,
|
||
},
|
||
Move {
|
||
start: Point,
|
||
ids: Vec<NodeId>,
|
||
targets: Targets,
|
||
duplicate: bool,
|
||
moved: bool,
|
||
},
|
||
Resize {
|
||
start: Point,
|
||
r: Box<ResizeStart>,
|
||
scale_tool: bool,
|
||
},
|
||
Rotate {
|
||
center: Point,
|
||
start_angle: f64,
|
||
ids: Vec<NodeId>,
|
||
},
|
||
Draw {
|
||
start: Point,
|
||
id: Option<NodeId>,
|
||
parent: NodeId,
|
||
targets: Targets,
|
||
},
|
||
Pan {
|
||
sx: f64,
|
||
sy: f64,
|
||
cx: f64,
|
||
cy: f64,
|
||
},
|
||
Pencil {
|
||
points: Vec<Point>,
|
||
},
|
||
TextSelect {
|
||
id: NodeId,
|
||
},
|
||
PenDrag {
|
||
anchor: Point,
|
||
},
|
||
/// Vector edit mode gestures (handled in `vectoredit`).
|
||
Vector(crate::vectoredit::VGesture),
|
||
/// Dragging an ellipse's arc handle: the arc so far (see [`crate::arc_handles`]).
|
||
Arc {
|
||
id: NodeId,
|
||
handle: ArcHandle,
|
||
arc: crate::arc_handles::Arc,
|
||
},
|
||
/// Shape builder: regions collected so far (handled in `shapebuilder`).
|
||
ShapeBuild {
|
||
picked: Vec<usize>,
|
||
remove: bool,
|
||
start: Point,
|
||
last: Point,
|
||
},
|
||
}
|
||
|
||
/// The pen tool's in-progress path (page coordinates).
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct PenPath {
|
||
pub id: Option<NodeId>,
|
||
pub parent: Option<NodeId>,
|
||
/// Anchors with (in, out) handle offsets.
|
||
pub points: Vec<(Point, Vec2, Vec2)>,
|
||
pub closed: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct ToolState {
|
||
pub tool: ToolId,
|
||
pub(crate) gesture: Gesture,
|
||
pub overlay: Overlay,
|
||
pub pen: Option<PenPath>,
|
||
/// Tool to return to after a temporary tool (space → hand).
|
||
pub temp_from: Option<ToolId>,
|
||
/// Last click (for double-click detection by agents that only send downs).
|
||
pub last_hover: Option<NodeId>,
|
||
/// Shape builder: the cached arrangement of the selection, and Remove mode (a plain click
|
||
/// removes; Alt merges).
|
||
pub shape_builder: Option<std::sync::Arc<crate::shapebuilder::Arrangement>>,
|
||
pub builder_remove: bool,
|
||
/// Show distances from the selection on hover without holding Alt (Dev mode inspection).
|
||
pub redlines: bool,
|
||
/// Pen snap targets, kept while hovering (see `vectoredit::point_snap`).
|
||
pub(crate) pen_targets: Option<crate::vectoredit::point_snap::PenTargets>,
|
||
/// The Scale tool's anchor: column and row (0 start, 1 middle, 2 end) of the point of the
|
||
/// selection box that stays put (`tool.scaleAnchor`).
|
||
pub scale_anchor: (u8, u8),
|
||
/// The last drag move handled (a held button repeats it every frame).
|
||
pub(crate) last_move: Option<PointerEvent>,
|
||
/// The name labels of top-level frames and sections as the canvas last drew them (each label's
|
||
/// corners on the page, turned with its frame): the Move and Scale tools treat a press or
|
||
/// hover on one as on its frame.
|
||
pub labels: Vec<(NodeId, [Point; 4])>,
|
||
}
|
||
|
||
/// 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<NodeId> {
|
||
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")
|
||
}
|
||
|
||
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;
|
||
|
||
/// 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<f64> {
|
||
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)
|
||
}
|
||
|
||
/// Which handle (if any) is under `p`.
|
||
pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<Handle> {
|
||
if sel.is_empty() {
|
||
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)
|
||
}
|
||
|
||
/// 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<Handle> {
|
||
let tol = HANDLE_PX / zoom.max(1e-6);
|
||
for (i, c) in corners.iter().enumerate() {
|
||
if is_line && (i == 3 || i == 2) {
|
||
continue;
|
||
}
|
||
if (*c - p).hypot() <= tol * 1.6 {
|
||
return Some(Handle::Corner(i as u8));
|
||
}
|
||
}
|
||
if !is_line {
|
||
for i in 0..4 {
|
||
let (Some(a), Some(b)) = (corners.get(i), corners.get((i + 1) % 4)) else { continue };
|
||
let seg = figcraft_geom::kurbo::Line::new(*a, *b);
|
||
use figcraft_geom::kurbo::ParamCurveNearest;
|
||
let near = seg.nearest(p, 0.001);
|
||
if near.distance_sq.sqrt() <= tol && near.t > 0.05 && near.t < 0.95 {
|
||
return Some(Handle::Edge(i as u8));
|
||
}
|
||
}
|
||
}
|
||
// Rotation zones: just outside each corner.
|
||
let center = Point::new(corners.iter().map(|c| c.x).sum::<f64>() / 4.0, corners.iter().map(|c| c.y).sum::<f64>() / 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();
|
||
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(_) => Cursor::Rotate,
|
||
}
|
||
}
|
||
|
||
/// The box (x0, y0, x1, y1) a `w0`×`h0` handle box becomes when `handle` is dragged to `q`, all in
|
||
/// the box's own axes: `keep` constrains proportions, `from_center` resizes about the centre.
|
||
/// Edges that cross over come back reversed (x1 < x0 or y1 < y0: a flip).
|
||
pub(crate) fn resized_box(handle: Handle, q: Point, w0: f64, h0: f64, keep: bool, from_center: bool) -> (f64, f64, f64, f64) {
|
||
let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, w0, h0);
|
||
match handle {
|
||
Handle::Corner(0) => {
|
||
x0 = q.x;
|
||
y0 = q.y;
|
||
}
|
||
Handle::Corner(1) => {
|
||
x1 = q.x;
|
||
y0 = q.y;
|
||
}
|
||
Handle::Corner(2) => {
|
||
x1 = q.x;
|
||
y1 = q.y;
|
||
}
|
||
Handle::Corner(_) => {
|
||
x0 = q.x;
|
||
y1 = q.y;
|
||
}
|
||
Handle::Edge(0) => y0 = q.y,
|
||
Handle::Edge(1) => x1 = q.x,
|
||
Handle::Edge(2) => y1 = q.y,
|
||
Handle::Edge(_) => x0 = q.x,
|
||
Handle::Rotate(_) => {}
|
||
}
|
||
if keep && w0 > 1e-9 && h0 > 1e-9 {
|
||
let (sx, sy) = ((x1 - x0) / w0, (y1 - y0) / h0);
|
||
let k = match handle {
|
||
Handle::Edge(0) | Handle::Edge(2) => sy,
|
||
Handle::Edge(_) => sx,
|
||
_ => {
|
||
if sx.abs() > sy.abs() {
|
||
sx
|
||
} else {
|
||
sy
|
||
}
|
||
}
|
||
};
|
||
let (nw, nh) = (w0 * k, h0 * k);
|
||
match handle {
|
||
Handle::Corner(0) => {
|
||
x0 = w0 - nw;
|
||
y0 = h0 - nh;
|
||
}
|
||
Handle::Corner(1) => {
|
||
x1 = nw;
|
||
y0 = h0 - nh;
|
||
}
|
||
Handle::Corner(2) => {
|
||
x1 = nw;
|
||
y1 = nh;
|
||
}
|
||
Handle::Corner(_) => {
|
||
x0 = w0 - nw;
|
||
y1 = nh;
|
||
}
|
||
Handle::Edge(0) | Handle::Edge(2) => {
|
||
let c = w0 / 2.0;
|
||
x0 = c - nw / 2.0;
|
||
x1 = c + nw / 2.0;
|
||
if matches!(handle, Handle::Edge(0)) {
|
||
y0 = h0 - nh;
|
||
} else {
|
||
y1 = nh;
|
||
}
|
||
}
|
||
Handle::Rotate(_) => {}
|
||
Handle::Edge(_) => {
|
||
let c = h0 / 2.0;
|
||
y0 = c - nh / 2.0;
|
||
y1 = c + nh / 2.0;
|
||
if matches!(handle, Handle::Edge(3)) {
|
||
x0 = w0 - nw;
|
||
} else {
|
||
x1 = nw;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if from_center {
|
||
let (cx, cy) = (w0 / 2.0, h0 / 2.0);
|
||
let hw = ((x1 - x0) / 2.0).abs().max((cx - x0).abs().max((x1 - cx).abs()));
|
||
let hh = ((y1 - y0) / 2.0).abs().max((cy - y0).abs().max((y1 - cy).abs()));
|
||
match handle {
|
||
Handle::Edge(0) | Handle::Edge(2) => {
|
||
y0 = cy - hh;
|
||
y1 = cy + hh;
|
||
}
|
||
Handle::Edge(_) => {
|
||
x0 = cx - hw;
|
||
x1 = cx + hw;
|
||
}
|
||
_ => {
|
||
x0 = cx - hw;
|
||
x1 = cx + hw;
|
||
y0 = cy - hh;
|
||
y1 = cy + hh;
|
||
}
|
||
}
|
||
}
|
||
(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<Point> {
|
||
let (f, w, h) = selection_frame(doc, sel)?;
|
||
Some(f * anchor_in_box(anchor, w, h))
|
||
}
|
||
|
||
/// The handle across the box from `h`.
|
||
fn opposite(h: Handle) -> Handle {
|
||
match h {
|
||
Handle::Corner(i) => Handle::Corner((i + 2) % 4),
|
||
Handle::Edge(i) => Handle::Edge((i + 2) % 4),
|
||
Handle::Rotate(i) => Handle::Rotate((i + 2) % 4),
|
||
}
|
||
}
|
||
|
||
/// The Scale tool's factor and pivot (in the box's axes) for dragging `handle` of a `w0`×`h0` box
|
||
/// to `q`. The pivot is the anchor point (`anchor`: column, row), or the opposite handle when the
|
||
/// anchor lies on the side being dragged, or the centre (`from_center`). Corners follow the axis
|
||
/// that moved the most; a negative factor flips. None when the box has no size to scale by.
|
||
pub(crate) fn scale_factor(handle: Handle, q: Point, w0: f64, h0: f64, anchor: (u8, u8), from_center: bool) -> Option<(f64, Point)> {
|
||
const EPS: f64 = 1e-9;
|
||
if !(q.x.is_finite() && q.y.is_finite() && w0.is_finite() && h0.is_finite()) || matches!(handle, Handle::Rotate(_)) {
|
||
return None;
|
||
}
|
||
let h = handle_point(handle, w0, h0);
|
||
let anchor_pt = anchor_in_box(anchor, w0, h0);
|
||
// The axes the handle moves along.
|
||
let (ax_x, ax_y) = match handle {
|
||
Handle::Edge(0) | Handle::Edge(2) => (false, true),
|
||
Handle::Edge(_) => (true, false),
|
||
_ => (true, true),
|
||
};
|
||
let on_side = (ax_x && (anchor_pt.x - h.x).abs() < EPS) || (ax_y && (anchor_pt.y - h.y).abs() < EPS);
|
||
let pivot = if from_center {
|
||
Point::new(w0 / 2.0, h0 / 2.0)
|
||
} else if on_side {
|
||
handle_point(opposite(handle), w0, h0)
|
||
} else {
|
||
anchor_pt
|
||
};
|
||
let ratio = |on: bool, to: f64, from: f64, p: f64| (on && (from - p).abs() > EPS).then(|| (to - p) / (from - p));
|
||
let kx = ratio(ax_x, q.x, h.x, pivot.x);
|
||
let ky = ratio(ax_y, q.y, h.y, pivot.y);
|
||
let k = match (kx, ky) {
|
||
(Some(a), Some(b)) => {
|
||
if a.abs() > b.abs() {
|
||
a
|
||
} else {
|
||
b
|
||
}
|
||
}
|
||
(Some(a), None) | (None, Some(a)) => a,
|
||
(None, None) => return None,
|
||
};
|
||
if !k.is_finite() {
|
||
return None;
|
||
}
|
||
// Never collapse to nothing (or blow up past a usable size); 0 counts as positive.
|
||
Some((k.signum() * k.abs().clamp(0.01, 1000.0), pivot))
|
||
}
|
||
|
||
impl Session {
|
||
pub fn set_tool(&mut self, t: ToolId) {
|
||
if self.tool.tool == ToolId::Pen && t != ToolId::Pen {
|
||
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.overlay = Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, ..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 t != ToolId::Text && t != ToolId::Move {
|
||
ds.text_edit = None;
|
||
}
|
||
ds.revision += 1;
|
||
}
|
||
}
|
||
|
||
/// 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.tool.tool;
|
||
if matches!(tool, ToolId::Move | ToolId::Scale) && 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 => 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::Pen => self.pen_down(ev),
|
||
ToolId::Pencil => {
|
||
self.tool.gesture = Gesture::Pencil { points: vec![ev.p()] };
|
||
Ok(())
|
||
}
|
||
ToolId::Comment => {
|
||
self.request_ui("comment.new", json!({"x": ev.x, "y": ev.y}));
|
||
Ok(())
|
||
}
|
||
ToolId::Eyedropper => self.eyedrop(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),
|
||
}
|
||
}
|
||
|
||
fn hover(&mut self, ev: &PointerEvent) -> Result<()> {
|
||
let tool = self.tool.tool;
|
||
let ds = self.ds()?;
|
||
let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page);
|
||
let mut cursor = match tool {
|
||
ToolId::Hand => Cursor::Grab,
|
||
ToolId::Text => Cursor::Text,
|
||
ToolId::Pen | ToolId::Pencil => Cursor::Pen,
|
||
t if t.draws_shape() => Cursor::Crosshair,
|
||
_ => Cursor::Default,
|
||
};
|
||
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;
|
||
if matches!(tool, ToolId::Move | ToolId::Scale) && arc_hover.is_none() {
|
||
if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom) {
|
||
cursor = resize_cursor(&doc, &sel, h);
|
||
} else {
|
||
hover = label_at(&doc, page, &self.tool.labels, ev.p())
|
||
.or_else(|| self.hit(&doc, page, ev.p(), ev.tol() * 2.0).map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)));
|
||
if ev.alt || self.tool.redlines {
|
||
use crate::distance::{Hover, guide_at, hover_lines, padding_frame, parent_of};
|
||
// Shift+Alt over an auto layout frame: its padding and gaps. Otherwise from the
|
||
// selection to a ruler guide, the hovered layer or (over empty canvas) the parent.
|
||
let around: Vec<NodeId> = 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]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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;
|
||
if let Some((last, _, _)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) {
|
||
self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]);
|
||
}
|
||
}
|
||
self.tool.overlay.guides = guides;
|
||
let ds = self.ds_mut()?;
|
||
if ds.hover != hover {
|
||
ds.hover = hover;
|
||
ds.revision += 1;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
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 ellipse's arc handles (drawn over the box), then the box's.
|
||
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 let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom) {
|
||
let ids = roots(&doc, &sel);
|
||
if let Handle::Rotate(_) = h {
|
||
let Some(b) = selection_box(&doc, &ids) else { return Ok(()) };
|
||
let center = Point::new(b.iter().map(|c| c.x).sum::<f64>() / 4.0, b.iter().map(|c| c.y).sum::<f64>() / 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,
|
||
};
|
||
return Ok(());
|
||
}
|
||
// A frame's name label picks the frame itself.
|
||
let target = label_at(&doc, page, &self.tool.labels, ev.p())
|
||
.or_else(|| self.hit(&doc, page, ev.p(), ev.tol() * 2.0).map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)));
|
||
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;
|
||
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 {
|
||
let mut s2 = sel.clone();
|
||
if let Some(pos) = s2.iter().position(|x| *x == target) {
|
||
s2.remove(pos);
|
||
} else {
|
||
s2.push(target);
|
||
}
|
||
s2
|
||
} else if sel.contains(&target) {
|
||
sel.clone()
|
||
} else {
|
||
vec![target]
|
||
};
|
||
self.set_selection(new_sel.clone());
|
||
if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) {
|
||
return Ok(());
|
||
}
|
||
let ids = roots(&doc, &new_sel);
|
||
let targets = Targets::gather(&doc, page, &ids);
|
||
self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" })?;
|
||
self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false };
|
||
self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default };
|
||
}
|
||
None => {
|
||
// Marquee; starting inside a top-level frame selects its children.
|
||
let within = frame_at(&doc, page, ev.p(), &[]).filter(|f| doc.node(*f).is_some_and(|n| n.parent == Some(page)));
|
||
let within = within.filter(|f| doc.world_bounds(*f).is_some_and(|b| b.contains(ev.p())));
|
||
// The focus view selects only inside its frame.
|
||
let within = self.ds()?.focused().or(within);
|
||
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]);
|
||
let doc = self.doc()?.clone();
|
||
let page = self.page()?;
|
||
// Cmd/Ctrl: the nested layers it touches.
|
||
let mut hits = if ev.cmd {
|
||
figcraft_doc::geometry::marquee_deep(&doc, page, r)
|
||
} else {
|
||
figcraft_doc::geometry::marquee_select(&doc, page, r, *within)
|
||
};
|
||
if let Some(f) = self.ds()?.focused() {
|
||
hits.retain(|h| *h != f && doc.is_ancestor(f, *h));
|
||
}
|
||
// A marquee over a frame from outside selects the frame only when it covers it fully.
|
||
if within.is_none() && !ev.cmd {
|
||
hits.retain(|h| {
|
||
let full = doc.world_bounds(*h).is_some_and(|b| r.contains(b.origin()) && r.contains(Point::new(b.x1, b.y1)));
|
||
full || !doc.node(*h).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }))
|
||
});
|
||
}
|
||
// Shift toggles: what was selected and is touched again comes out.
|
||
let mut sel: Vec<NodeId> = base.iter().copied().filter(|b| !(*additive && hits.contains(b))).collect();
|
||
for h in hits {
|
||
if !base.contains(&h) && !sel.contains(&h) {
|
||
sel.push(h);
|
||
}
|
||
}
|
||
self.set_selection(sel);
|
||
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;
|
||
}
|
||
}
|
||
let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd;
|
||
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(doc.duplicate_into(*i, parent, idx)?);
|
||
}
|
||
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
|
||
// an instance (outside its slots: they only move); the rest may change parent.
|
||
let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute))
|
||
&& !home.is_some_and(|h| crate::components::structure_locked(doc, h));
|
||
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<NodeId> = 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.gesture = g.clone();
|
||
if let Gesture::Move { moved, .. } = &mut self.tool.gesture {
|
||
*moved = true;
|
||
}
|
||
return Ok(());
|
||
}
|
||
Gesture::Resize { start, r, scale_tool } => self.resize_drag(ev, *start, r, *scale_tool),
|
||
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(())
|
||
})?;
|
||
self.tool.overlay.cursor = Cursor::Rotate;
|
||
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::Arc { id, handle, arc } => {
|
||
let (id, handle, arc) = (*id, *handle, *arc);
|
||
self.tool.gesture = g;
|
||
return self.arc_drag(ev, id, handle, arc);
|
||
}
|
||
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 } => {
|
||
let mut pts = points.clone();
|
||
if pts.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) {
|
||
pts.push(ev.p());
|
||
}
|
||
self.tool.overlay.pen = pts.iter().map(|p| [p.x, p.y]).collect();
|
||
self.tool.gesture = Gesture::Pencil { points: pts };
|
||
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 { .. } => Ok(()),
|
||
Gesture::PenDrag { anchor } => {
|
||
let out = 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;
|
||
let ids: Vec<NodeId> = 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;
|
||
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;
|
||
self.preview(|doc, _| {
|
||
if single {
|
||
let Some((id, world, _, _)) = nodes.first() else { return Ok(()) };
|
||
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]);
|
||
}
|
||
doc.set_world_transform(*id, t)?;
|
||
crate::cmd::props::resize_node(doc, *id, nw.max(0.01), nh.max(0.01))?;
|
||
let n = doc.get_mut(*id)?;
|
||
if matches!(handle, Handle::Corner(_) | Handle::Edge(1) | Handle::Edge(3)) {
|
||
n.child_layout.sizing_h = Sizing::Fixed;
|
||
}
|
||
if matches!(handle, Handle::Corner(_) | Handle::Edge(0) | Handle::Edge(2)) {
|
||
n.child_layout.sizing_v = Sizing::Fixed;
|
||
}
|
||
if let NodeKind::Text(t) = &mut n.kind {
|
||
t.auto_resize = match handle {
|
||
Handle::Edge(1) | Handle::Edge(3) => TextAutoResize::Height,
|
||
_ => TextAutoResize::None,
|
||
};
|
||
}
|
||
return Ok(());
|
||
}
|
||
// Several layers: map the old box onto the new one, in the box's axes.
|
||
let (sx, sy) = (if w0 > 1e-9 { nw / w0 } else { 1.0 }, if h0 > 1e-9 { nh / h0 } else { 1.0 });
|
||
let inv = frame.inverse();
|
||
for (id, world, w, h) in &nodes {
|
||
let local = inv * *world;
|
||
let o = local * Point::ZERO;
|
||
let c = local.as_coeffs();
|
||
doc.set_world_transform(*id, frame * Affine::new([c[0], c[1], c[2], c[3], nx0 + o.x * sx, ny0 + o.y * sy]))?;
|
||
crate::cmd::props::resize_node(doc, *id, w * sx, h * sy)?;
|
||
}
|
||
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<Guide>) -> 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<NodeId>, 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 {
|
||
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();
|
||
}
|
||
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.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, .. } => {
|
||
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 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 {
|
||
// Instance layers stay put (outside slots): they only move.
|
||
let from = d.node(*m).and_then(|n| n.parent);
|
||
if from.is_some_and(|f| crate::components::structure_locked(d, f)) || crate::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) is a
|
||
// structure edit only its main can make: the gesture is undone.
|
||
let ds = self.ds()?;
|
||
let allowed =
|
||
ds.interaction.as_ref().is_none_or(|it| moving.iter().all(|m| crate::components::move_allowed(&it.doc, &ds.doc, *m)));
|
||
if allowed {
|
||
self.commit_interaction()?;
|
||
} else {
|
||
self.cancel_interaction()?;
|
||
}
|
||
} else {
|
||
self.cancel_interaction()?;
|
||
// Plain click on an already-selected layer among several: select just it.
|
||
let _ = ev;
|
||
}
|
||
}
|
||
Gesture::Resize { .. } | Gesture::Rotate { .. } | Gesture::Arc { .. } => 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 tool = self.tool.tool;
|
||
let (w, h) = match tool {
|
||
ToolId::Line | ToolId::Arrow => (100.0, 0.0),
|
||
ToolId::Section => (400.0, 300.0),
|
||
_ => (100.0, 100.0),
|
||
};
|
||
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;
|
||
}
|
||
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 { points } => {
|
||
self.tool.overlay.pen.clear();
|
||
self.pencil_finish(&points)?;
|
||
}
|
||
Gesture::TextSelect { .. } | Gesture::Marquee { .. } | Gesture::None => {}
|
||
Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } => {}
|
||
Gesture::Vector(_) => self.commit_interaction()?,
|
||
}
|
||
if let Ok(ds) = self.ds_mut() {
|
||
ds.revision += 1;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
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 Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) };
|
||
let target = select_target(&doc, leaf, &sel, true);
|
||
// Text: edit at the click.
|
||
if doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))
|
||
&& (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target)
|
||
{
|
||
let idx = self.text_index_at(target, ev.p())?;
|
||
textedit::begin(self, target, Some(idx), false)?;
|
||
textedit::select_word(self, idx)?;
|
||
return Ok(());
|
||
}
|
||
// Shapes and vectors: enter vector edit mode.
|
||
if doc.node(target).is_some_and(|n| n.kind.is_shape()) && sel.contains(&target) {
|
||
return crate::vectoredit::begin(self, target);
|
||
}
|
||
// Groups/frames: select one level deeper (the child under the pointer).
|
||
let deeper =
|
||
select_target(&doc, leaf, &[select_target(&doc, leaf, &sel, false)].into_iter().chain(sel.iter().copied()).collect::<Vec<_>>(), false);
|
||
if deeper != target || !sel.contains(&deeper) {
|
||
self.set_selection(vec![deeper]);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The char index in text node `id` at page point `p`.
|
||
pub fn text_index_at(&self, id: NodeId, p: Point) -> Result<usize> {
|
||
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((w, h)) = figcraft_text::auto_size(&n) {
|
||
n.width = w.max(1.0);
|
||
n.height = h;
|
||
}
|
||
d.insert(n, parent, None)?;
|
||
ds.selection = vec![id];
|
||
textedit::begin(self, id, Some(0), true)?;
|
||
if !self.prefs.keep_tool {
|
||
self.tool.tool = ToolId::Move;
|
||
}
|
||
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 !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<Guide>)> {
|
||
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();
|
||
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)
|
||
};
|
||
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();
|
||
}
|
||
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()
|
||
{
|
||
let v = p - *last;
|
||
let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0;
|
||
p = *last + Vec2::from_angle(ang) * v.hypot();
|
||
}
|
||
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();
|
||
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();
|
||
}
|
||
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(())
|
||
}
|
||
|
||
/// Finish the pen path (Enter/Escape/double-click).
|
||
pub fn pen_finish(&mut self) -> Result<()> {
|
||
let pen = self.tool.pen.take();
|
||
self.tool.overlay.pen.clear();
|
||
self.tool.overlay.rubber = 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]) -> 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();
|
||
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;
|
||
self.execute(
|
||
"node.create",
|
||
&json!({"type": "vector", "path": local.to_svg(), "closed": false, "parent": parent, "name": "Vector",
|
||
"props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 2, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"}}),
|
||
)?;
|
||
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<bool> {
|
||
if self.ds().is_ok_and(|d| d.vector_edit.is_some()) {
|
||
return crate::vectoredit::key(self, name, shift, alt, cmd);
|
||
}
|
||
crate::shapebuilder::key(self, name)
|
||
}
|
||
|
||
/// The layer under `p` for a click: the topmost on `page`, or in the focus view the focused
|
||
/// frame's.
|
||
pub fn hit(&self, doc: &Document, page: NodeId, p: Point, tol: f64) -> Option<NodeId> {
|
||
match self.ds().ok().and_then(crate::DocState::focused) {
|
||
Some(f) => figcraft_doc::geometry::hit_in(doc, f, p, tol),
|
||
None => doc.hit_test(page, p, tol),
|
||
}
|
||
}
|
||
|
||
/// 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<()> {
|
||
if self.tool.pen.is_some() {
|
||
return self.pen_finish();
|
||
}
|
||
if self.tool.busy() {
|
||
self.tool.gesture = Gesture::None;
|
||
self.tool.overlay = Overlay::default();
|
||
return self.cancel_interaction();
|
||
}
|
||
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<NodeId> =
|
||
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 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 { .. });
|
||
self.tool.tool == ToolId::Move
|
||
&& (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<crate::arc_handles::ArcHandles> {
|
||
let ds = self.ds().ok()?;
|
||
self.arc_handles_shown().then(|| crate::arc_handles::handles(&ds.doc, &ds.selection, ds.view.zoom)).flatten()
|
||
}
|
||
|
||
/// Snapshot of tool state for the UI / inspect.
|
||
pub fn tool_info(&self) -> serde_json::Value {
|
||
json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles()})
|
||
}
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn unused(_: BTreeMap<u8, u8>) {}
|