[MM4] A group's box follows its children after a command moves one

shape.move or shape.setBounds on a group's child left the group's box as it
was, so inspect_slide reported stale bounds. After any edit that changes a
child's box or the child list, the group's box is refitted to the union of its
children (its child space too, so every child keeps its place on the slide).
A group that was itself moved or resized, and SmartArt groups (regenerated
inside their own box), are left alone.

PowerPoint: a group's bounding box always hugs its children; dragging one
child out grows the group, dragging it back shrinks it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Zach Berry
2026-10-27 20:38:41 -05:00
co-authored by Claude Sonnet 5.5
parent 55e5ee56b4
commit 2cad063d1d
3 changed files with 116 additions and 0 deletions
+92
View File
@@ -229,6 +229,98 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// After an edit: refit every group whose children's geometry changed to the union of its
/// children (PowerPoint keeps a group's box on its children, so moving one child grows or
/// shrinks the group). The children keep their place on the slide: the group's box and its child
/// space change together. `old` is the presentation before the edit; only slides the edit
/// touched are looked at, and a group whose own box changed (it was moved or resized) is left to
/// that edit.
pub(crate) fn refit_groups(old: &deckcraft_model::Presentation, new: &mut deckcraft_model::Presentation) {
// Each slide, master and layout is compared with its own earlier state, found by id: a move,
// insertion or deletion shifts the indices, and shape ids repeat across slides.
let old_slides: std::collections::HashMap<_, _> = old.slides.iter().map(|s| (s.id, s)).collect();
for slide in new.slides.iter_mut() {
let Some(before) = old_slides.get(&slide.id) else { continue };
if std::sync::Arc::ptr_eq(before, slide) {
continue;
}
let shapes = &mut std::sync::Arc::make_mut(slide).shapes;
refit_list(Some(&before.shapes), shapes, 0);
}
// A child edited in Slide Master, layout, notes or handout view refits its group there too.
for master in new.masters.iter_mut() {
if let Some(before) = old.masters.iter().find(|m| m.id == master.id)
&& !std::sync::Arc::ptr_eq(before, master)
{
refit_master(before, std::sync::Arc::make_mut(master));
}
}
for (before, after) in [(old.notes_master.as_ref(), new.notes_master.as_mut()), (old.handout_master.as_ref(), new.handout_master.as_mut())] {
if let (Some(before), Some(after)) = (before, after)
&& !std::sync::Arc::ptr_eq(before, after)
{
refit_master(before, std::sync::Arc::make_mut(after));
}
}
}
fn refit_master(old: &deckcraft_model::Master, new: &mut deckcraft_model::Master) {
refit_list(Some(&old.shapes), &mut new.shapes, 0);
for layout in new.layouts.iter_mut() {
refit_list(old.layouts.iter().find(|l| l.id == layout.id).map(|l| &l.shapes), &mut layout.shapes, 0);
}
}
fn refit_list(old: Option<&Vec<Shape>>, list: &mut [Shape], depth: usize) {
// Most edits touch a slide without groups: nothing to refit, and no per-shape lookup. Nesting
// is bounded like the model's other walks.
if depth > 64 || !list.iter().any(|sh| matches!(sh.kind, ShapeKind::Group { .. })) {
return;
}
let by_id: std::collections::HashMap<ShapeId, &Shape> = old.map(|o| o.iter().map(|x| (x.id, x)).collect()).unwrap_or_default();
for sh in list.iter_mut() {
let before = by_id.get(&sh.id).copied();
let old_children = before.and_then(|b| match &b.kind {
ShapeKind::Group { children, .. } => Some(children),
_ => None,
});
let own_before = before.map(|b| {
(
b.xfrm,
match &b.kind {
ShapeKind::Group { child, .. } => Some(*child),
_ => None,
},
)
});
let smart = sh.diagram.is_some();
let ShapeKind::Group { children, child } = &mut sh.kind else { continue };
refit_list(old_children, children, depth + 1);
if smart {
// A SmartArt graphic's box is its own; its shapes are regenerated inside it.
continue;
}
let (Some(old_children), Some((old_x, old_child))) = (old_children, own_before) else { continue };
if old_x != sh.xfrm || old_child != Some(*child) || children.is_empty() {
continue;
}
let same = old_children.len() == children.len() && old_children.iter().zip(children.iter()).all(|(a, b)| a.id == b.id && a.xfrm == b.xfrm);
if same {
continue;
}
let Some(group) = sh.xfrm else { continue };
let xs: Vec<Xfrm> = children.iter().filter_map(|c| c.xfrm).collect();
let Some(u) = union_bounds(&xs) else { continue };
if ![u.x0, u.y0, u.x1, u.y1].iter().all(|v| v.is_finite()) {
continue;
}
let union = Xfrm::new(u.x0, u.y0, u.width(), u.height());
let placed = child_to_parent(&group, child, &union);
sh.xfrm = Some(Xfrm { x: placed.x, y: placed.y, w: placed.w, h: placed.h, rot: group.rot, flip_h: group.flip_h, flip_v: group.flip_v });
*child = union;
}
}
/// A child's xfrm in slide space, given its group.
pub fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm {
let sx = if ch.w.abs() > 1e-9 { group.w / ch.w } else { 1.0 };
+1
View File
@@ -695,6 +695,7 @@ impl Session {
let mut doc = (*st.doc).clone();
let mut sel = st.selection.clone();
let r = f(&mut doc, &mut sel)?;
cmd::arrange::refit_groups(&st.doc, &mut doc);
connect::reroute(&mut doc, &sel);
st.doc = Arc::new(doc);
st.selection = sel;
+23
View File
@@ -5646,3 +5646,26 @@ fn invalid_ids_array_never_falls_back_to_the_selection() {
assert!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a as u32)).is_some(), "shape deleted for {ids}");
}
}
/// MM4: moving a group's child through a command refits the group's box to its children, as
/// PowerPoint does, and the children stay where they were drawn.
#[test]
fn moving_a_group_child_refits_the_group() {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let ins = |s: &mut Session, p: serde_json::Value| s.execute("shape.insert", &p).unwrap()["id"].as_u64().unwrap();
let a = ins(&mut s, json!({"preset": "rect", "rect": [100, 100, 100, 50]}));
let b = ins(&mut s, json!({"preset": "rect", "rect": [300, 100, 100, 50]}));
let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
let gb = |s: &Session| s.doc().unwrap().shape(ShapeId(g as u32)).unwrap().xfrm.unwrap();
assert_eq!((gb(&s).x, gb(&s).w), (100.0, 300.0));
s.execute("shape.move", &json!({"id": b, "dx": 100, "dy": 60})).unwrap();
let x = gb(&s);
assert_eq!((x.x, x.y, x.w, x.h), (100.0, 100.0, 400.0, 110.0), "group follows its children: {x:?}");
// The child is where it was moved to on the slide.
let r = s.execute("slide.inspect", &json!({})).unwrap();
assert!(r.to_string().contains("400"), "{r}");
s.execute("shape.setBounds", &json!({"ids": [a], "x": 20, "y": 20, "w": 100, "h": 50})).unwrap();
let x = gb(&s);
assert_eq!((x.x, x.y, x.w), (20.0, 20.0, 480.0), "{x:?}");
}