From 3cd2fddeda2ec5c03929185c771b1059925e0552 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 01:41:51 +0700 Subject: [PATCH] Shape tools: Space moves Arc, Spiral and the grids too, through one helper (review of #304) The Line Segment family (Arc, Spiral, Rectangular Grid, Polar Grid) ignored Space while drawing, as the shape tools did before #302. Both tools now share shape::space_moves. A headless canvas test presses Space for real in the middle of a Rectangle and a Pen drag: the shape and the anchor move and the view doesn't pan. Adds the ROADMAP entry. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/tools/src/draw2/family.rs | 20 ++++++++++----- crates/tools/src/shape.rs | 17 +++++++----- crates/ui-egui/src/canvas.rs | 44 ++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 53725bf9..0b2611fe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -105,7 +105,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Tools:** - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. - **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. + - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. - **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 (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. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. diff --git a/crates/tools/src/draw2/family.rs b/crates/tools/src/draw2/family.rs index 0c066a66..4821f9d9 100644 --- a/crates/tools/src/draw2/family.rs +++ b/crates/tools/src/draw2/family.rs @@ -1,8 +1,9 @@ //! Line Segment family drag tools: Arc, Spiral, Rectangular Grid and Polar Grid. //! -//! Drag draws (Shift = equal axes / square, Alt = from the centre for arc and grids); a click -//! without dragging asks the UI for the options dialog. While dragging, ↑/↓ change the spiral's -//! segments, the grid rows or the concentric dividers; ←/→ change grid columns / radial dividers. +//! Drag draws (Shift = equal axes / square, Alt = from the centre for arc and grids, Space held +//! moves the shape being drawn); a click without dragging asks the UI for the options dialog. +//! While dragging, ↑/↓ change the spiral's segments, the grid rows or the concentric dividers; ←/→ +//! change grid columns / radial dividers. use serde_json::{Value, json}; use vectorcraft_geom::{Point, Rect, Vec2}; @@ -120,8 +121,9 @@ impl Tool for FamilyTool { vec![] } PointerKind::Drag => { - let Some(s) = self.start else { return vec![] }; - self.last = ev.pos; + let Some(mut s) = self.start else { return vec![] }; + crate::shape::space_moves(&mut s, &mut self.last, ev, self.began); + self.start = Some(s); self.mods = ev.mods; let mut out = vec![]; if !self.began { @@ -238,7 +240,13 @@ mod tests { ); let a = t.key(&cx, ToolKey::Right, Mods::default()); assert!(matches!(&a[0], Action::Preview(_, v) if v["columns"] == 6)); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 60.0, 40.0)), vec![Action::Commit]); + // Space held moves the grid at its size; let go, it grows again from there. + let space = Mods { space: true, ..Mods::default() }; + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 50.0).with_mods(space)); + assert!(matches!(&a[..], [Action::Preview(_, v)] if v["x"] == 30.0 && v["y"] == 20.0 && v["width"] == 50.0 && v["height"] == 30.0), "{a:?}"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 60.0)); + assert!(matches!(&a[..], [Action::Preview(_, v)] if v["x"] == 30.0 && v["width"] == 60.0 && v["height"] == 40.0), "{a:?}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 90.0, 60.0)), vec![Action::Commit]); } #[test] diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index e5e3d51a..708db37c 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -92,6 +92,15 @@ impl ShapeTool { } } +/// Space held once a shape drag has begun moves the shape being drawn instead of sizing it: `start` +/// moves as far as the pointer did since `last`, which becomes the pointer. +pub(crate) fn space_moves(start: &mut Point, last: &mut Point, ev: &PointerEvent, began: bool) { + if ev.mods.space && began { + *start += ev.pos - *last; + } + *last = ev.pos; +} + /// The rectangle a drag from `start` to `p` draws: Shift makes it a square, Alt draws it from its /// centre. pub(crate) fn drag_rect(start: Point, p: Point, m: Mods) -> Rect { @@ -124,12 +133,8 @@ impl Tool for ShapeTool { let (pos, g) = crate::guides::snap_draw(cx, ev.pos, &[]); self.guides = g; let ev = &PointerEvent { pos, ..*ev }; - // Space held while drawing: the shape moves with the pointer instead of growing. - if ev.mods.space && self.began { - s += ev.pos - self.last; - self.start = Some(s); - } - self.last = ev.pos; + space_moves(&mut s, &mut self.last, ev, self.began); + self.start = Some(s); self.mods = ev.mods; let mut out = vec![]; if !self.began { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9d8fa8e7..afb4d87e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1655,6 +1655,50 @@ mod tests { assert!((after.center.x - (before.center.x - 30.0 / before.zoom)).abs() < 1e-6, "{before:?} → {after:?}"); } + #[test] + fn space_pressed_mid_drag_moves_what_the_tool_draws() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.ui.view.smart_guides = false; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + // Press at `a`, drag to `b`, then hold Space while moving on by `by` and let go of both. + let draw = |app: &mut VectorcraftApp, a: Pos2, b: Pos2, by: egui::Vec2| { + frame(app, &ctx, vec![Event::PointerMoved(a)]); + frame(app, &ctx, vec![button(a, true)]); + frame(app, &ctx, vec![Event::PointerMoved(b)]); + frame(app, &ctx, vec![space(true)]); + frame(app, &ctx, vec![Event::PointerMoved(b + by)]); + frame(app, &ctx, vec![space(false)]); + frame(app, &ctx, vec![button(b + by, false)]); + }; + let near = |p: Point, q: Point| p.distance(q) < 1e-3; + let at = app.canvas_rect.unwrap().center() - vec2(100.0, 60.0); + let center = app.view().unwrap().center; + // The rectangle moves at its size instead of growing, and the view doesn't pan. + app.select_tool("rectangle"); + draw(&mut app, at, at + vec2(80.0, 40.0), vec2(30.0, 20.0)); + assert_eq!(app.view().unwrap().center, center, "Space during a drag doesn't pan"); + let st = app.session.active().unwrap(); + let r = st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().bounds().unwrap(); + assert!( + near(Point::new(r.x0, r.y0), xf.to_doc(at + vec2(30.0, 20.0))) && near(Point::new(r.x1, r.y1), xf.to_doc(at + vec2(110.0, 60.0))), + "{r:?}" + ); + // The Pen's anchor being dragged out moves, handles and all. + app.session.execute("select.none", &json!({})).unwrap(); + app.select_tool("pen"); + let a = at + vec2(0.0, 120.0); + draw(&mut app, a, a + vec2(40.0, 0.0), vec2(20.0, 10.0)); + let st = app.session.active().unwrap(); + let anchor = st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().subpaths[0].anchors[0]; + assert!(near(anchor.p, xf.to_doc(a + vec2(20.0, 10.0))) && near(anchor.h_out, xf.to_doc(a + vec2(60.0, 10.0))), "{anchor:?}"); + } + #[test] fn double_clicking_type_with_the_selection_tool_puts_the_caret_there() { let mut app = VectorcraftApp::new(Session::new(), Default::default());