From 337a2e02f59df06b72df57dee6eeb80996367445 Mon Sep 17 00:00:00 2001 From: SofiaAFigueredo Date: Wed, 11 Nov 2026 09:51:18 -0300 Subject: [PATCH] Fix #4312: a Shift move lands the selection's edges and centre on ruler guides main lines a moved selection's edges and centre up with ruler guides (snap_moved_to_rulers, #5746) but not when Shift keeps the move to its line. The bounds now slide along that line onto a ruler guide the same way (snap_moved_along_to_rulers), when nothing else lined up and the grabbed point is not on a guide, within twice the Snap to Point distance along the line. Co-Authored-By: Claude Sonnet 5.5 --- crates/tools/src/guides/targets.rs | 41 ++++++++++++++++++++++++++++++ crates/tools/src/guides/tests.rs | 19 ++++++++++++++ crates/tools/src/select.rs | 8 ++++++ crates/tools/src/select/tests.rs | 24 +++++++++++++++++ docs/target-app-parity.md | 1 + 5 files changed, 93 insertions(+) diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index e4039e6d9..c50889d03 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -1194,6 +1194,47 @@ impl Targets { (d, ov, held.contains(&true)) } + /// [`Self::snap_moved_to_rulers`] for a selection a move Shift keeps to its line (through the + /// grabbed point `p`, along `dir`), its bounds now `r`: the edge or centre of `r` that a ruler + /// guide runs past within `tol` goes onto it by sliding along the line, the way needing the + /// shortest slide winning and at most twice `tol` (a line nearly parallel to a guide would + /// carry the selection far to meet it). Returns the shift along the line and the guides. + pub(crate) fn snap_moved_along_to_rulers(&self, p: Point, r: Rect, dir: Vec2, tol: f64) -> (Vec2, Vec) { + let len = dir.hypot(); + if !len.is_finite() || len <= 1e-9 { + return (Vec2::ZERO, vec![]); + } + let u = dir / len; + // The way along the line, where the guide is and where it starts, and whether it is upright. + let mut best: Option<(f64, f64, Point, bool)> = None; + for (vertical, c) in [(true, u.x), (false, u.y)] { + if c.abs() <= 1e-9 { + continue; + } + let vs = if vertical { [r.x0, r.center().x, r.x1] } else { [r.y0, r.center().y, r.y1] }; + let (mid, half) = + if vertical { (Point::new(0.0, r.center().y), r.height() / 2.0) } else { (Point::new(r.center().x, 0.0), r.width() / 2.0) }; + for (t, from, _) in self.rulers.iter().filter(|g| g.vertical == vertical).filter_map(|g| g.line_at(mid, tol + half)) { + for v in vs { + let s = (t - v) / c; + if (t - v).abs() <= tol && s.abs() <= 2.0 * tol && best.is_none_or(|b| s.abs() < b.0.abs()) { + best = Some((s, t, from, vertical)); + } + } + } + } + let Some((s, t, from, vertical)) = best else { return (Vec2::ZERO, vec![]) }; + let moved = r + u * s; + let line = if vertical { + self.line(Point::new(t, from.y.min(moved.y0)), Point::new(t, from.y.max(moved.y1))) + } else { + self.line(Point::new(from.x.min(moved.x0), t), Point::new(from.x.max(moved.x1), t)) + }; + let mut ov: Vec = line.into_iter().collect(); + ov.extend(self.label(p + u * s, "align")); + (u * s, ov) + } + /// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for /// dragging `handle` of the box `bx` (about the centre when `from_center`), in the box's own /// frame, and so is the result. The handle lands on an anchor or centre within `tol` on the page diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs index 499410465..c34f9ab7d 100644 --- a/crates/tools/src/guides/tests.rs +++ b/crates/tools/src/guides/tests.rs @@ -22,6 +22,25 @@ fn snaps_to_anchor_and_alignment() { assert_eq!(p.y, 200.0); } +/// #4312: a Shift move slides the selection along its line onto a ruler guide, but not far to +/// meet one a nearly parallel line only reaches a long way off. +#[test] +fn a_slide_along_a_line_lands_the_bounds_on_a_ruler_guide_within_reach() { + let t = Targets { rulers: vec![Ruler { vertical: true, pos: 300.0, span: None }], ..Targets::default() }; + let r = Rect::new(248.0, 10.0, 298.0, 40.0); + let p = Point::new(270.0, 25.0); + // Level: the right edge is 2 short of the guide, which is the way along the line. + assert_eq!(t.snap_moved_along_to_rulers(p, r, Vec2::new(1.0, 0.0), 4.0).0, Vec2::new(2.0, 0.0)); + // On the diagonal it is 2.8 along. + let (d, ov) = t.snap_moved_along_to_rulers(p, r, Vec2::new(1.0, 1.0), 4.0); + assert!((d - Vec2::new(2.0, 2.0)).hypot() < 1e-9 && !ov.is_empty(), "{d:?}"); + // Nearly upright, it would take about 118 along the line to meet the guide. + assert_eq!(t.snap_moved_along_to_rulers(p, r, Vec2::new(0.017, 1.0), 4.0).0, Vec2::ZERO); + // A guide of an artboard needs the selection to run past it. + let bounded = Targets { rulers: vec![Ruler { vertical: true, pos: 300.0, span: Some((100.0, 200.0)) }], ..Targets::default() }; + assert_eq!(bounded.snap_moved_along_to_rulers(p, r, Vec2::new(1.0, 0.0), 4.0).0, Vec2::ZERO); +} + #[test] fn rect_snap_aligns_edges() { let (d, id) = doc_with_rect(); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index fe666bd67..8dbdb461e 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -368,6 +368,14 @@ impl MoveSnap { if q != start + d { d = q - start; guides = ov; + } else if guides.is_empty() + && let Some(b) = self.bounds + { + // Nothing else lined up: the bounds slide along the line onto a ruler guide, as + // they do without Shift (#4312). + let (adj, ov) = t.snap_moved_along_to_rulers(start + d, b + d, u, cx.tol(cx.snap_tolerance)); + d += adj; + guides = ov; } } if cx.snap_to_pixel diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 2b1151742..93cbc8ee7 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -770,6 +770,30 @@ fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() { assert_eq!(dx(&ToolContext { snap_to_point: false, ..cx(&d, &s, &p) }, 172.0), 22.0); } +/// #4312: a Shift move keeps to its line while its edges and centre land on a ruler guide, as a +/// move without Shift does (#5746). +#[test] +fn a_shift_move_lands_the_edges_on_a_ruler_guide() { + let (mut d, id) = doc_with_rect(); + d.guides = vec![vectorcraft_doc::Guide::new(true, 333.0)]; + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + // Level; the left edge (100) would end 2 short of the guide, the pointer far from it. + let (m, _) = shift_moved(&c, (150.0 + 231.0, 150.0)); + assert!(moved_by(m, (233.0, 0.0)), "{m:?}"); + // The right edge, 1 past the guide. + let (m, _) = shift_moved(&c, (150.0 + 134.0, 150.0)); + assert!(moved_by(m, (133.0, 0.0)), "{m:?}"); + // Out of reach, or with Snap to Point off, the move stays as dragged. + let (m, _) = shift_moved(&c, (150.0 + 221.0, 150.0)); + assert!(moved_by(m, (221.0, 0.0)), "{m:?}"); + let off = ToolContext { snap_to_point: false, ..cx(&d, &s, &p) }; + let (m, _) = shift_moved(&off, (150.0 + 231.0, 150.0)); + assert!(moved_by(m, (231.0, 0.0)), "{m:?}"); +} + /// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as /// gathered before the previews moved it); released, the move doesn't. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 42889ae13..d2910337b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2125,6 +2125,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | 2026-10-20 | minor | Type core: Hyphenation… and Justification… no longer listed as missing (done in #2310, #2755); evidence: a paragraph's Auto Leading drives the Character panel's Leading, the leading nudge and Fill with Placeholder Text; scores unchanged | | 2026-10-22 | minor | Drawing tools: a Pen, Anchor Point or Direct Selection drag's preview no longer stalls (#834: the position where the pointer re-entered the window could be skipped, and the move in the release's frame was lost); score and hours unchanged (estimated) | | 2026-10-23 | minor | Guides and snapping: a path moved by a press on its stroke or beside its edge (Selection, Group Selection, Direct Selection), and a straight segment Direct Selection drags, snap by the nearest point of the path, so the path lands exactly on a ruler guide with "align" instead of the pressed spot landing there and the path a few pixels beside it (#3701); `tests_prefs::a_move_held_by_a_paths_stroke_lands_the_path_on_a_ruler_guide`, `tests_prefs::a_straight_segment_dragged_by_direct_selection_lands_on_a_ruler_guide`. Score and hours unchanged. | +| 2026-11-11 | minor | Guides and snapping: a Shift move slides the selection along its line onto a ruler guide by its edges and centre, as a move without Shift does (#4312); a guide a nearly parallel line only reaches a long way off pulls nothing. Score and hours unchanged (estimated). | | 2026-10-22 | minor | Live vector effects: the Transform effect has Transform Objects, Transform Patterns and Scale Strokes & Effects, its checkboxes visible (#885); the area row now lists only the options on a fill's or stroke's own Transform effect. Score and hours unchanged (estimated). | | 2026-10-23 | minor | COLR colour fonts (v0 and v1) draw and export as vector art; evidence added; scores unchanged. | | 2026-10-22 | minor | Libraries panel: the buttons beside the library picker show the items as icons (the default) or in a list, colours and graphics as rows with their names (a colour's hex value too), dragged and dropped by row; graphic thumbnails are shrunk to the size they are drawn at, so thin strokes stay whole. Tests: `panels::libraries::tests::the_list_view_shows_names_and_takes_drops_by_row`, `the_panel_draws_its_libraries`. Scores and hours unchanged. |