diff --git a/ROADMAP.md b/ROADMAP.md index 3a81040f..4afa8867 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,6 +134,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) | +| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) | | 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort | | 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape | | 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dd64b08c..996e3cb9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4072,3 +4072,73 @@ fn a_shape_in_front_of_edited_text_takes_the_click() { let st = s.doc().unwrap(); assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 != tb), "the click on the member in front left the text: {:?}", st.selection); } + +/// A box at `rect` and a second box at `other` on a fresh slide; smart guides on, the grid off. +/// The first box is selected. +fn snap_scene(rect: [f64; 4], other: [f64; 4]) -> (Session, u64) { + let mut s = session(); + s.prefs.snap_to_grid = false; + s.execute("shape.insert", &json!({"preset": "rect", "rect": other})).unwrap(); + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": rect})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + s.prefs.smart_guides = true; + (s, id) +} + +/// The slide box (`x`, `y`, `w`, `h`) of shape `id`, as `shape.inspect` reports it. +fn box_of(s: &mut Session, id: u64) -> serde_json::Value { + s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone() +} + +/// #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] +fn resizing_snaps_the_dragged_edge_when_the_fixed_edge_is_already_aligned() { + // The box spans x 100..200; the other box starts at 100 too and ends at 300. + let (mut s, id) = snap_scene([100.0, 100.0, 100.0, 80.0], [100.0, 300.0, 200.0, 80.0]); + drag_gesture(&mut s, (200.0, 140.0), (297.0, 140.0)); + let b = box_of(&mut s, id); + assert_eq!(b["x"], 100.0, "the fixed edge stays: {b}"); + assert_eq!(b["w"], 200.0, "the right edge snaps to the other box's right edge at 300: {b}"); +} + +/// #961: only the dragged edge looks for a line. A fixed edge near a line must not shift the +/// edge being dragged by its own distance. +#[test] +fn resizing_is_not_shifted_by_a_fixed_edge_near_a_line() { + // The other box starts at 103, 3 pt from the fixed left edge; the dragged edge ends far from any line. + let (mut s, id) = snap_scene([100.0, 100.0, 100.0, 80.0], [103.0, 300.0, 50.0, 80.0]); + drag_gesture(&mut s, (200.0, 140.0), (421.0, 140.0)); + let b = box_of(&mut s, id); + assert_eq!(b["x"], 100.0, "the fixed edge stays: {b}"); + assert_eq!(b["w"], 321.0, "the dragged edge goes where the pointer went: {b}"); +} + +/// #961: dragging a handle past the opposite edge flips the box, and the dragged edge becomes the +/// other side of it: that side snaps, not the fixed one. +#[test] +fn resizing_past_the_opposite_edge_snaps_the_dragged_edge() { + // East handle of the box at x 100..200, dragged left past its left edge to 3, 3 pt from the + // other box's left edge at 0. + let (mut s, id) = snap_scene([100.0, 100.0, 100.0, 80.0], [0.0, 300.0, 50.0, 80.0]); + drag_gesture(&mut s, (200.0, 140.0), (3.0, 140.0)); + let b = box_of(&mut s, id); + assert_eq!((b["x"].clone(), b["w"].clone()), (json!(0.0), json!(100.0)), "the dragged edge snaps to 0, the fixed edge stays at 100: {b}"); + // South handle of the box at y 100..180, dragged up past its top edge to 3. + let (mut s, id) = snap_scene([100.0, 100.0, 100.0, 80.0], [300.0, 0.0, 80.0, 50.0]); + drag_gesture(&mut s, (150.0, 180.0), (150.0, 3.0)); + let b = box_of(&mut s, id); + assert_eq!((b["y"].clone(), b["h"].clone()), (json!(0.0), json!(100.0)), "the dragged edge snaps to 0, the fixed edge stays at 100: {b}"); +} + +/// #961: the handles of a flipped box sit on the other side (the east handle draws on the left), +/// and the edge under the dragged handle snaps. +#[test] +fn resizing_a_flipped_box_snaps_the_dragged_edge() { + // Flipped box at x 100..200: its east handle is at x 100. Dragged to 3, 3 pt from the other box's left edge at 0. + let (mut s, id) = snap_scene([100.0, 100.0, 100.0, 80.0], [0.0, 300.0, 50.0, 80.0]); + s.execute("shape.flip", &json!({"axis": "horizontal"})).unwrap(); + drag_gesture(&mut s, (100.0, 140.0), (3.0, 140.0)); + let b = box_of(&mut s, id); + assert_eq!((b["x"].clone(), b["w"].clone()), (json!(0.0), json!(200.0)), "the dragged edge snaps to 0, the fixed edge stays at 200: {b}"); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index a7190b73..a41d5f06 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1449,6 +1449,13 @@ impl Session { /// Smart guides: snap `r` edges/centres to the slide and other shapes. Returns the offset to /// apply and the guides to draw. fn snap_rect(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool) -> (Vec2, Vec) { + self.snap_edges(r, exclude, tol, enabled, [true; 3], [true; 3]) + } + + /// `snap_rect` looking only at some of `r`'s edges: `xs_on` is left, centre, right and `ys_on` + /// top, centre, bottom. Resizing passes just the edges being dragged, so a fixed edge that + /// already sits on a line (distance 0) doesn't win and leave the dragged edge unsnapped. + fn snap_edges(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool, xs_on: [bool; 3], ys_on: [bool; 3]) -> (Vec2, Vec) { let Some(st) = self.active() else { return (Vec2::ZERO, vec![]) }; if !enabled || !self.prefs.smart_guides { return (Vec2::ZERO, vec![]); @@ -1486,7 +1493,7 @@ impl Session { } } let mut best_x: Option<(f64, f64, Rect)> = None; - for edge in [r.x0, r.center().x, r.x1] { + for edge in [r.x0, r.center().x, r.x1].into_iter().zip(xs_on).filter_map(|(e, on)| on.then_some(e)) { for (x, b) in &xs { let d = x - edge; if d.abs() <= tol && best_x.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) { @@ -1495,7 +1502,7 @@ impl Session { } } let mut best_y: Option<(f64, f64, Rect)> = None; - for edge in [r.y0, r.center().y, r.y1] { + for edge in [r.y0, r.center().y, r.y1].into_iter().zip(ys_on).filter_map(|(e, on)| on.then_some(e)) { for (y, b) in &ys { let d = y - edge; if d.abs() <= tol && best_y.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) { @@ -1509,32 +1516,49 @@ impl Session { off.x = d; let rr = r + Vec2::new(d, 0.0); guides.push(Guide { vertical: true, pos: x, from: b.y0.min(rr.y0), to: b.y1.max(rr.y1) }); - } else if self.prefs.snap_to_grid { - off.x = deckcraft_geom::snap(r.x0, self.prefs.grid_spacing) - r.x0; + } else if self.prefs.snap_to_grid && xs_on.contains(&true) { + let at = if xs_on[0] { + r.x0 + } else if xs_on[2] { + r.x1 + } else { + r.center().x + }; + off.x = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at; } if let Some((d, y, b)) = best_y { off.y = d; let rr = r + Vec2::new(0.0, d); guides.push(Guide { vertical: false, pos: y, from: b.x0.min(rr.x0), to: b.x1.max(rr.x1) }); - } else if self.prefs.snap_to_grid { - off.y = deckcraft_geom::snap(r.y0, self.prefs.grid_spacing) - r.y0; + } else if self.prefs.snap_to_grid && ys_on.contains(&true) { + let at = if ys_on[0] { + r.y0 + } else if ys_on[2] { + r.y1 + } else { + r.center().y + }; + off.y = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at; } (off, guides) } + /// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides. fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm { if x.rot != 0.0 { self.tool.guides.clear(); return x; } let r = x.rect(); - let (off, guides) = self.snap_rect(r, &[id], tol * 1.5, true); + // Only the dragged edges move, so only they look for a line to snap to. The handles sit in + // the box's unflipped frame: in a flipped box, also one that a drag past the opposite edge + // just flipped, the dragged handle is on the other side. + let (left, right) = (matches!(handle, 0 | 6 | 7), matches!(handle, 2..=4)); + 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 mut nx = x; - // Only the dragged edges move. - let moves_left = matches!(handle, 0 | 6 | 7); - let moves_right = matches!(handle, 2..=4); - let moves_top = matches!(handle, 0..=2); - let moves_bottom = matches!(handle, 4..=6); if moves_left { nx.x += off.x; nx.w -= off.x;