Merge pull request #269 from theironchef/fix/free-transform-modifiers

Free Transform: a handle drag follows the modifiers held during the drag
This commit is contained in:
BFlat 2026-10-07 12:31:52 +07:00 committed by GitHub
commit 333a487dab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 106 additions and 22 deletions

View File

@ -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.

View File

@ -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<Point> = 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();

View File

@ -44,6 +44,27 @@ enum Op {
Corners(Handle, DistortMode),
}
impl Op {
/// The bounding-box handle being dragged, if any.
fn handle(self) -> Option<Handle> {
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<Op> {
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();