diff --git a/ROADMAP.md b/ROADMAP.md index dacb44f1..a27acde3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -106,7 +106,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). - - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (distort/perspective). + - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). - **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. - **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View. diff --git a/crates/engine/src/tests_xform.rs b/crates/engine/src/tests_xform.rs index 705070bc..9ca4f43e 100644 --- a/crates/engine/src/tests_xform.rs +++ b/crates/engine/src/tests_xform.rs @@ -148,6 +148,27 @@ fn free_transform_distort_mode_via_pointer() { assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort")); } +#[test] +fn free_transform_cmd_held_once_a_corner_drag_started_distorts_in_one_step() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let v = ViewInfo::default(); + s.select_tool("freeTransform", v).unwrap(); + let before = undo_len(&s); + let cmd = Mods { cmd: true, ..Default::default() }; + s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 210.0, 210.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(cmd), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 260.0).with_mods(cmd), v).unwrap(); + let d = &s.doc().unwrap().doc; + let pts: Vec = d.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect(); + for p in [Point::new(250.0, 260.0), Point::new(100.0, 100.0), Point::new(200.0, 100.0), Point::new(100.0, 200.0)] { + assert!(pts.iter().any(|q| q.distance(p) < 1e-6), "{p:?} not in {pts:?}"); + } + assert_eq!(undo_len(&s), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Distort")); +} + #[test] fn free_transform_side_handle_scales() { let mut s = session(); diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs index 8ee8db89..1fa70074 100644 --- a/crates/tools/src/xform/free.rs +++ b/crates/tools/src/xform/free.rs @@ -44,6 +44,27 @@ enum Op { Corners(Handle, DistortMode), } +impl Op { + /// The bounding-box handle being dragged, if any. + fn handle(self) -> Option { + match self { + Op::Scale(h) | Op::Shear(h) | Op::Corners(h, _) => Some(h), + Op::Move | Op::Rotate => None, + } + } + + /// The undo step's name. + fn label(self) -> &'static str { + match self { + Op::Move => "Move", + Op::Scale(_) => "Scale", + Op::Shear(_) => "Shear", + Op::Rotate => "Rotate", + Op::Corners(..) => "Distort", + } + } +} + #[derive(Clone, Copy, Debug)] struct Drag { op: Op, @@ -125,22 +146,28 @@ pub fn distort_quad(r: Rect, h: Handle, d: Vec2, mode: DistortMode) -> [Point; 4 } impl FreeTransformTool { + /// What dragging handle `h` with modifiers `m` does: Cmd on a corner distorts it freely, + /// Cmd+Alt+Shift in perspective, Cmd on a side shears; otherwise the tool's mode. + fn handle_op(&self, h: Handle, m: Mods) -> Op { + let mode = if h.is_corner() && m.cmd && m.alt && m.shift { + DistortMode::Perspective + } else if h.is_corner() && m.cmd { + DistortMode::Distort + } else { + self.mode + }; + match mode { + DistortMode::Free if !h.is_corner() && m.cmd => Op::Shear(h), + DistortMode::Free => Op::Scale(h), + DistortMode::Perspective if !h.is_corner() => Op::Scale(h), + mode => Op::Corners(h, mode), + } + } + fn classify(&self, cx: &ToolContext, r: Rect, p: Point, m: Mods) -> Option { let tol = cx.tol(5.0); if let Some(h) = hit_handle(r, p, tol) { - let mode = if h.is_corner() && m.cmd && m.alt && m.shift { - DistortMode::Perspective - } else if h.is_corner() && m.cmd { - DistortMode::Distort - } else { - self.mode - }; - return Some(match mode { - DistortMode::Free if !h.is_corner() && m.cmd => Op::Shear(h), - DistortMode::Free => Op::Scale(h), - DistortMode::Perspective if !h.is_corner() => Op::Scale(h), - mode => Op::Corners(h, mode), - }); + return Some(self.handle_op(h, m)); } if self.mode == DistortMode::Free && in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() { return Some(Op::Rotate); @@ -205,19 +232,24 @@ impl Tool for FreeTransformTool { PointerKind::Drag => { let Some(mut d) = self.drag else { return vec![] }; let mut out = vec![]; + // A handle drag follows the modifiers held now: the gesture is to start dragging + // and then hold Cmd (a Cmd press on the canvas is the temporary Selection tool). + if let Some(h) = d.op.handle() { + let op = self.handle_op(h, ev.mods); + if d.began && op.label() != d.op.label() { + // Another operation: it gets an undo step of its own. + out.push(Action::Cancel); + out.push(Action::Begin(op.label().into())); + } + d.op = op; + } if !d.began { if p.distance(d.start) < cx.tol(2.0) { + self.drag = Some(d); return out; } d.began = true; - let label = match d.op { - Op::Move => "Move", - Op::Scale(_) => "Scale", - Op::Shear(_) => "Shear", - Op::Rotate => "Rotate", - Op::Corners(..) => "Distort", - }; - out.push(Action::Begin(label.into())); + out.push(Action::Begin(d.op.label().into())); } self.drag = Some(d); out.push(self.preview(cx, d, p, ev.mods)); @@ -353,6 +385,37 @@ mod tests { assert!((m * Point::new(100.0, 100.0)).distance(Point::new(120.0, 100.0)) < 1e-9); } + #[test] + fn modifiers_pressed_during_a_handle_drag_change_what_it_does() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = FreeTransformTool::default(); + let cmd = Mods { cmd: true, ..Default::default() }; + let perspective = Mods { cmd: true, alt: true, shift: true, ..Default::default() }; + // Start dragging the bottom-right corner, then hold Cmd: Free Distort, a step of its own. + t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 210.0, 210.0)); + assert_eq!(a[0], Action::Begin("Scale".into())); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 220.0, 230.0).with_mods(cmd)); + let corners = json!({"corners": [[100.0, 100.0], [200.0, 100.0], [220.0, 230.0], [100.0, 200.0]], "from": [100.0, 100.0, 200.0, 200.0]}); + assert_eq!(a, vec![Action::Cancel, Action::Begin("Distort".into()), Action::Preview("object.distort".into(), corners)]); + // Cmd+Alt+Shift: Perspective Distort, still the same step. + let a = t.pointer(&cx, &ev(PointerKind::Drag, 220.0, 230.0).with_mods(perspective)); + assert!(matches!(&a[..], [Action::Preview(c, _)] if c == "object.distort"), "{a:?}"); + // Letting go of the keys scales again. + let a = t.pointer(&cx, &ev(PointerKind::Drag, 220.0, 230.0)); + assert_eq!(a[..2], [Action::Cancel, Action::Begin("Scale".into())]); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 220.0, 230.0)), vec![Action::Commit]); + // Cmd held during a side handle's drag shears. + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 100.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 155.0, 100.0)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(cmd)); + assert_eq!(a[..2], [Action::Cancel, Action::Begin("Shear".into())]); + } + #[test] fn distort_modes_emit_corners() { let (d, id) = doc_with_rect();