diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4dfc21f4..b72dcbd7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5479,6 +5479,138 @@ fn box_of(s: &mut Session, id: u64) -> serde_json::Value { s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone() } +/// Two selected boxes with different sizes and aligned right edges, on a blank slide. +fn resize_selection_scene() -> (Session, [u64; 2]) { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.prefs.smart_guides = false; + s.prefs.snap_to_grid = false; + let ids = [[100, 100, 100, 80], [120, 300, 80, 100]] + .map(|rect| s.execute("shape.insert", &json!({"preset": "rect", "rect": rect, "text": "Resize test"})).unwrap()["id"].as_u64().unwrap()); + s.execute("edit.select", &json!({"ids": ids})).unwrap(); + (s, ids) +} + +/// Simulate file-loaded shapes whose stored boxes have not yet been fitted to their text. +fn pending_resize_autofit(s: &mut Session, ids: &[u64]) { + s.edit(|doc, sel| { + let shapes = crate::shapes_mut(doc, sel).unwrap(); + for id in ids { + let sh = deckcraft_model::find_shape_mut(shapes, ShapeId(*id as u32)).unwrap(); + sh.text.as_mut().unwrap().body.autofit = Some(deckcraft_model::text::AutoFit::Shape); + } + Ok(()) + }) + .unwrap(); +} + +#[test] +fn resizing_selected_shapes_is_one_undo_step() { + let (mut s, [a, b]) = resize_selection_scene(); + let before = s.doc().unwrap().doc.clone(); + let steps = s.doc().unwrap().history.undo.len(); + let down = drag_gesture(&mut s, (200.0, 180.0), (250.0, 210.0)); + assert_eq!(down["hit"]["kind"], "resize"); + assert_eq!(down["hit"]["id"], a); + for (id, expected) in [(a, [100.0, 100.0, 150.0, 110.0]), (b, [120.0, 300.0, 130.0, 130.0])] { + let actual = box_of(&mut s, id); + for (key, value) in ["x", "y", "w", "h"].into_iter().zip(expected) { + assert_eq!(actual[key], value, "{id}: {key}"); + } + } + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let after = s.doc().unwrap().doc.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before, "one undo restores both shapes"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after, "one redo resizes both shapes again"); +} + +#[test] +fn resizing_selected_shapes_refits_text_in_the_same_undo_step() { + let (mut s, ids) = resize_selection_scene(); + pending_resize_autofit(&mut s, &ids); + let before = s.doc().unwrap().doc.clone(); + let steps = s.doc().unwrap().history.undo.len(); + // An east-handle drag changes width only; a height change must come from text fitting. + drag_gesture(&mut s, (200.0, 140.0), (250.0, 140.0)); + for (id, old_height) in ids.into_iter().zip([80.0, 100.0]) { + assert_ne!(box_of(&mut s, id)["h"], old_height, "both shapes' text is refitted"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let after = s.doc().unwrap().doc.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after, "redo includes the final text fit"); +} + +#[test] +fn clicking_a_resize_handle_does_not_change_or_refit_selected_shapes() { + let (mut s, ids) = resize_selection_scene(); + pending_resize_autofit(&mut s, &ids); + let before = s.doc().unwrap().doc.clone(); + let steps = s.doc().unwrap().history.undo.len(); + let revision = s.doc().unwrap().revision; + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(PointerEvent { kind, x: 200.0, y: 180.0, tol: 3.0, ..Default::default() }).unwrap(); + } + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "a click creates no undo entry"); + assert_eq!(s.doc().unwrap().revision, revision); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "a click does not dirty the document"); + // Ensure this fixture would catch an accidental refit on release. + for id in ids { + cmd::text::fit_text_box(&mut s, ShapeId(id as u32)).unwrap(); + } + assert_ne!(s.doc().unwrap().doc, before); +} + +#[test] +fn resizing_selected_shapes_skips_locked_shapes_and_their_text_fit() { + let (mut s, [a, b]) = resize_selection_scene(); + pending_resize_autofit(&mut s, &[b]); + s.execute("shape.lock", &json!({"ids": [b], "locked": true})).unwrap(); + s.execute("edit.select", &json!({"ids": [a, b]})).unwrap(); + let locked = s.doc().unwrap().shape(ShapeId(b as u32)).unwrap().clone(); + let before = s.doc().unwrap().doc.clone(); + let steps = s.doc().unwrap().history.undo.len(); + drag_gesture(&mut s, (200.0, 180.0), (250.0, 210.0)); + assert_eq!(box_of(&mut s, a)["w"], 150.0); + assert_eq!(box_of(&mut s, a)["h"], 110.0); + assert_eq!(s.doc().unwrap().shape(ShapeId(b as u32)).unwrap(), &locked, "the locked shape is neither resized nor refitted"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); +} + +#[test] +fn resizing_selected_shapes_does_not_snap_to_other_resize_targets() { + for reverse in [false, true] { + let (mut s, [a, b]) = resize_selection_scene(); + s.prefs.smart_guides = true; + let ids = if reverse { [b, a] } else { [a, b] }; + s.execute("edit.select", &json!({"ids": ids})).unwrap(); + // Within snap tolerance of the other selected shape's original right edge. + drag_gesture(&mut s, (200.0, 140.0), (203.0, 140.0)); + assert_eq!(box_of(&mut s, a)["w"], 103.0, "first shape must not snap back"); + assert_eq!(box_of(&mut s, b)["w"], 83.0, "selection order must not affect snapping"); + } +} + +#[test] +fn resizing_selected_shapes_still_snaps_to_stationary_shapes() { + let (mut s, [a, b]) = resize_selection_scene(); + let fixed = s.execute("shape.insert", &json!({"preset": "rect", "rect": [207, 430, 100, 80]})).unwrap()["id"].as_u64().unwrap(); + let fixed_before = box_of(&mut s, fixed); + s.execute("edit.select", &json!({"ids": [a, b]})).unwrap(); + s.prefs.smart_guides = true; + drag_gesture(&mut s, (200.0, 140.0), (203.0, 140.0)); + assert_eq!(box_of(&mut s, a)["w"], 107.0); + assert_eq!(box_of(&mut s, b)["w"], 87.0); + assert_eq!(box_of(&mut s, fixed), fixed_before); +} + /// #961: dragging the east handle snaps the dragged (right) edge, even when the fixed left edge /// already lines up with another shape. The nearest line used to be the fixed edge's, at distance 0. #[test] diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index 5660a09a..7dca3822 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -27,12 +27,10 @@ impl Session { return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol); } Drag::Resize { ids, handle, origs, start } => { - if ids.len() >= 1 { - for (id, o) in &origs { - let nx = resize_box(&o, handle, p - start, mods.shift || self.is_picture(*id), mods.alt); - let nx = self.snap_resize(nx, &o, handle, *id, tol); - self.set_xfrm(*id, nx)?; - } + for (id, o) in &origs { + let nx = resize_box(o, handle, p - start, mods.shift || self.is_picture(*id), mods.alt); + let nx = self.snap_resize(nx, handle, &ids, tol); + self.set_xfrm(*id, nx)?; } } Drag::LineEnd { id, end, orig } => { diff --git a/crates/engine/src/tools/geometry.rs b/crates/engine/src/tools/geometry.rs index 536a4ead..cbbd0127 100644 --- a/crates/engine/src/tools/geometry.rs +++ b/crates/engine/src/tools/geometry.rs @@ -228,7 +228,7 @@ impl Session { } /// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides. - pub(super) fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm { + pub(super) fn snap_resize(&mut self, x: Xfrm, handle: u8, exclude: &[ShapeId], tol: f64) -> Xfrm { if x.rot != 0.0 { self.tool.guides.clear(); return x; @@ -241,7 +241,7 @@ impl Session { let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6)); let (moves_left, moves_right) = if x.flip_h { (right, left) } else { (left, right) }; let (moves_top, moves_bottom) = if x.flip_v { (bottom, top) } else { (top, bottom) }; - let (off, guides) = self.snap_edges(r, &[id], tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]); + let (off, guides) = self.snap_edges(r, exclude, tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]); let mut nx = x; if moves_left { nx.x += off.x; diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs index 50d11c39..dbaf495f 100644 --- a/crates/engine/src/tools/pointer.rs +++ b/crates/engine/src/tools/pointer.rs @@ -303,7 +303,8 @@ impl Session { Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?, Some(Hit::Resize { id, handle }) => { let ids: Vec = if st.selection.shapes.contains(&id) { st.selection.shapes.clone() } else { vec![id] }; - let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| {st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))}).collect(); + let origs: Vec<(ShapeId, Xfrm)> = + ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); let ids: Vec = origs.iter().map(|(id, _)| *id).collect(); self.begin("Size"); self.tool.drag = Some(Drag::Resize { ids, handle, origs, start: p }); @@ -562,12 +563,10 @@ impl Session { } } Some(Drag::Resize { origs, .. }) => { - let changed: Vec = origs.iter() + let changed: Vec = origs + .iter() .filter_map(|(id, orig)| { - let current = self.active() - .and_then(|st| st.shape(*id).map(|s| { - xfrm_of(&st.doc, &st.selection, s) - }))?; + let current = self.active().and_then(|st| st.shape(*id).map(|s| xfrm_of(&st.doc, &st.selection, s)))?; (current != *orig).then_some(*id) })