Snap: Shift and Command on a move

This commit is contained in:
Cameron Gilroy 2026-10-06 00:09:15 +11:00 committed by Brandon Thomas
parent 55ec575521
commit a2451243ad
3 changed files with 118 additions and 13 deletions

View File

@ -72,6 +72,40 @@ fn tool_gesture_creates_one_undo_step() {
assert_eq!(s.doc().unwrap().doc.spreads[0].items[0].bounds().x0, 36.0);
}
#[test]
fn shift_move_does_not_take_the_other_axis() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
s.execute("frame.create", &json!({"rect": [80.0, 100.0, 140.0, 140.0], "content": "unassigned"})).unwrap();
s.execute("guide.add", &json!({"orientation": "horizontal", "position": 102.0, "page": 0})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
let shift = designcraft_tools::Mods { shift: true, ..Default::default() };
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 120.0).with_mods(shift), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 160.0, 121.0).with_mods(shift), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 121.0).with_mods(shift), v).unwrap();
let b = s.doc().unwrap().doc.spreads[0].items[0].bounds();
assert!((b.y0 - 100.0).abs() < 1e-6, "locked axis jumped to {b:?}");
assert!(b.x0 > 80.0);
}
#[test]
fn cmd_move_does_not_snap() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
s.execute("frame.create", &json!({"rect": [80.0, 100.0, 140.0, 140.0], "content": "unassigned"})).unwrap();
s.execute("guide.add", &json!({"orientation": "horizontal", "position": 102.0, "page": 0})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
let cmd = designcraft_tools::Mods { cmd: true, shift: false, ..Default::default() };
s.pointer(&PointerEvent::new(PointerKind::Down, 110.0, 120.0).with_mods(cmd), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 160.0, 121.0).with_mods(cmd), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 121.0).with_mods(cmd), v).unwrap();
let b = s.doc().unwrap().doc.spreads[0].items[0].bounds();
assert!((b.y0 - 101.0).abs() < 1e-6, "cmd move snapped to {b:?}");
assert!(b.x0 > 80.0);
}
#[test]
fn pages_and_layers() {
let mut s = session();

View File

@ -8,7 +8,7 @@ use designcraft_doc::SpreadRef;
use designcraft_geom::{Point, Rect, Vec2};
use serde_json::{Value, json};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, rect_json, spread_json};
use crate::{Action, Cursor, Gesture, Mods, Overlay, PointerEvent, PointerKind, SnapRequest, Tool, ToolContext, ToolKey, rect_json, spread_json};
#[derive(Clone, Debug)]
enum Drag {
@ -54,11 +54,13 @@ pub struct SelectionTool {
drag: Drag,
hover_handle: Option<usize>,
guides: Vec<Overlay>,
/// Selected item to toggle if this Shift press never becomes a drag.
shift_release: Option<u64>,
}
impl SelectionTool {
pub fn new(direct: bool) -> Self {
Self { direct, drag: Drag::None, hover_handle: None, guides: vec![] }
Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None }
}
}
@ -183,6 +185,7 @@ impl Tool for SelectionTool {
vec![]
}
PointerKind::Down => {
self.shift_release = None;
if self.direct
&& let Some((id, si, ai, handle)) = anchor_at(cx, p)
{
@ -221,7 +224,12 @@ impl Tool for SelectionTool {
let id = if self.direct { id } else { cx.doc.top_level_of(id).unwrap_or(id) };
let mut out = vec![];
if ev.mods.shift {
out.push(Action::Exec("selection.toggle".into(), json!({"id": id.0})));
if cx.selection.contains(id) {
// Keep it selected so a Shift-drag can constrain. A click still toggles, on release.
self.shift_release = Some(id.0);
} else {
out.push(Action::Exec("selection.toggle".into(), json!({"id": id.0})));
}
} else if !cx.selection.contains(id) {
out.push(Action::Exec("selection.set".into(), json!({"ids": [id.0], "content": self.direct})));
}
@ -240,6 +248,7 @@ impl Tool for SelectionTool {
if (p - start).hypot() < cx.tol(3.0) {
return vec![];
}
self.shift_release = None;
if hit && !cx.selection.items.is_empty() {
let sr = cx.selection.items.first().and_then(|i| cx.doc.find(*i)).map(|l| l.spread).unwrap_or(SpreadRef::Doc(0));
self.drag = Drag::Move { start, origin_spread: sr, bounds0: cx.selection_bounds() };
@ -254,23 +263,49 @@ impl Tool for SelectionTool {
}
Drag::Move { start, origin_spread, bounds0 } => {
let mut d: Vec2 = p - start;
let mut x_edges = [true, true, true];
let mut y_edges = [true, true, true];
if ev.mods.shift {
if d.x.abs() > d.y.abs() {
d.y = 0.0;
y_edges = [false, false, false];
} else {
d.x = 0.0;
x_edges = [false, false, false];
}
}
self.guides.clear();
// The movement in the spread's own coordinates (its view may be turned).
let mut ds = cx.layout.delta_to_spread(origin_spread, d);
let b0s = bounds0.map(|b| cx.layout.xf(origin_spread).inverse().transform_rect_bbox(b));
if cx.snap.any()
// Command suspends snapping. A Shift-locked axis is not offered.
if !ev.mods.cmd
&& cx.snap.any()
&& let Some(b0) = b0s
{
let snap = crate::snap::snap_rect(cx, origin_spread, b0 + ds, &cx.selection.items);
ds += snap.delta;
self.guides = snap.guides;
let hit = crate::snap::snap(
cx,
SnapRequest {
spread: origin_spread,
gesture: Gesture::Move,
rect: b0 + ds,
x_edges,
y_edges,
exclude: &cx.selection.items,
copying: ev.mods.alt,
lengths: [None, None],
angle: None,
radius: 0.0,
pointer: cx.layout.to_spread(origin_spread, p),
},
);
if x_edges != [false, false, false] {
ds.x += hit.delta.x;
}
if y_edges != [false, false, false] {
ds.y += hit.delta.y;
}
self.guides = hit.guides;
}
// Dragging to another spread moves the items there.
let target = cx.layout.spread_at(p).map(|(s, _)| s).unwrap_or(origin_spread);
@ -318,9 +353,14 @@ impl Tool for SelectionTool {
},
PointerKind::Up => {
self.guides.clear();
let release = self.shift_release.take();
let d = std::mem::replace(&mut self.drag, Drag::None);
match d {
Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } => vec![Action::Commit],
Drag::Pending { .. } => match release {
Some(id) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))],
None => vec![],
},
Drag::Marquee { start, cur } => {
let r = Rect::from_points(start, cur);
// Items whose bounds intersect the marquee.
@ -337,7 +377,7 @@ impl Tool for SelectionTool {
}
vec![Action::Exec("selection.set".into(), json!({"ids": ids, "add": ev.mods.shift}))]
}
_ => vec![],
Drag::None => vec![],
}
}
PointerKind::DoubleClick => {

View File

@ -2,8 +2,7 @@
//!
//! [`snap`] tries each pass on a free axis and keeps the first hit inside the snap zone.
//! Ruler guides, margins, and columns draw nothing. Alignment draws [`Overlay::Guide`] in
//! canvas coordinates. [`snap_rect`] is the older closest-target helper the selection tool
//! still calls.
//! canvas coordinates. [`snap_rect`] is the older closest-target helper.
use designcraft_doc::{ItemId, Orientation, SpreadRef};
use designcraft_geom::{Point, Rect};
@ -263,8 +262,9 @@ fn pass_guides(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) ->
/// Page and top-level item edges and centers. Draws one [`Overlay::Guide`] per winning axis.
///
/// Edges match edges only while `align_edges` is on. Centers match centers only while
/// `align_centers` is on, and only when the request's middle flag is set. Hidden and excluded
/// items are skipped. Parent items and group children are not targets.
/// `align_centers` is on, and only when the request's middle flag is set. Hidden items are
/// skipped. Excluded items are skipped unless `copying` is set, so an Alt-drag can meet the
/// original. Parent items and group children are not targets.
fn pass_align(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
if !cx.snap.smart_guides || (!cx.snap.align_edges && !cx.snap.align_centers) {
return None;
@ -276,7 +276,7 @@ fn pass_align(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) ->
offer_box(&mut best, req.rect, flags, axis, page.bounds(), tol, cx.snap.align_edges, cx.snap.align_centers);
}
for item in &sp.items {
if req.exclude.contains(&item.id) || item.hidden {
if item.hidden || (!req.copying && req.exclude.contains(&item.id)) {
continue;
}
offer_box(&mut best, req.rect, flags, axis, item.visible_bounds(), tol, cx.snap.align_edges, cx.snap.align_centers);
@ -521,4 +521,35 @@ mod tests {
);
assert_eq!(hit.delta.x, 0.0);
}
#[test]
fn alt_copy_snaps_to_the_original() {
let mut doc = Document::new(&NewDocument::default());
let layer = doc.default_layer();
let id = ItemId(doc.alloc());
let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 40.0, 160.0, 80.0)));
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
let cache = Cache::new();
let layout = CanvasLayout::new(&doc, false);
let sel = Selection::default();
let mut cx = ctx_on(&doc, &sel, &cache, &layout);
cx.snap.snap_to_guides = false;
let hit = snap(
&cx,
SnapRequest {
spread: SpreadRef::Doc(0),
gesture: Gesture::Move,
rect: Rect::new(162.0, 40.0, 222.0, 80.0),
x_edges: [true, false, false],
y_edges: [false, false, false],
exclude: &[id],
copying: true,
lengths: [None, None],
angle: None,
radius: 0.0,
pointer: Point::new(0.0, 0.0),
},
);
assert!((hit.delta.x - -2.0).abs() < 1e-6);
}
}