From 04069e19682c3c20d19ad7492dc53fb4b8f6879d Mon Sep 17 00:00:00 2001 From: Alexander Alber <90604107+alexanderalber@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:03:01 +0200 Subject: [PATCH] Morph: text boxes without a partner fade in place instead of moving (#119) The last matching pass paired any two shapes of one kind and preset geometry, so a text box leaving the slide was paired with an unrelated one arriving and flew across to it. That pass now only pairs shapes without text: a text box needs its name or its text on the other slide to morph, otherwise it fades out (or in) where it is, as in PowerPoint. generated with Claude Code --- crates/anim/src/morph.rs | 7 +++++-- crates/anim/src/tests.rs | 20 ++++++++++++++++++++ docs/animation-parity.md | 3 ++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/anim/src/morph.rs b/crates/anim/src/morph.rs index b5808c4f..1d4ab8f3 100644 --- a/crates/anim/src/morph.rs +++ b/crates/anim/src/morph.rs @@ -12,7 +12,9 @@ fn text_of(s: &Shape) -> String { /// Pairs of (shape on `a`, shape on `b`) that Morph animates from one to the other: first by /// `!!name` (forced match), then by exact name, then by the same kind and text, then by the same -/// kind and preset geometry. Each shape is used once; top-level shapes only (groups move whole). +/// kind and preset geometry if neither carries text. A text box with neither a name nor its text on +/// the other slide stays unpaired and fades in place, as in PowerPoint (#119). Each shape is used +/// once; top-level shapes only (groups move whole). pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> { let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new(); let mut used_a = vec![false; a.shapes.len()]; @@ -31,7 +33,8 @@ pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> { x.kind_name() == y.kind_name() && x.geom.preset_name().is_some() && x.geom.preset_name() == y.geom.preset_name() - && text_of(x).trim().is_empty() == text_of(y).trim().is_empty() + && text_of(x).trim().is_empty() + && text_of(y).trim().is_empty() }, ]; for pass in passes { diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index 1fd82d78..de9c40ab 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -529,6 +529,26 @@ fn morph_pairs_by_name_text_and_geometry() { assert!(morph_pairs(&Slide::default(), &b).is_empty()); } +#[test] +fn morph_pairs_leaves_unrelated_text_boxes_alone() { + // A text box that leaves and an unrelated one that arrives fade in place (#119). + let p = deckcraft_model::defaults::new_presentation(None); + let mut a = Slide::default(); + let mut b = Slide::default(); + let tb = |id, name, x, text| Shape { xfrm: Some(Xfrm::new(x, 100.0, 200.0, 100.0)), text: Some(TextBody::from_text(text)), ..shape(id, name) }; + a.shapes = vec![tb(1, "TextBox 1", 0.0, "Gone")]; + b.shapes = vec![tb(2, "TextBox 2", 400.0, "New")]; + assert!(morph_pairs(&a, &b).is_empty()); + let f = morph_frame(&p, &a, &b, 0.5, &|_| false); + let xs: Vec<(&str, f64)> = f.shapes.iter().map(|(s, _)| (s.name.as_str(), s.xfrm.map_or(f64::NAN, |x| x.x))).collect(); + assert_eq!(xs, [("TextBox 1", 0.0), ("TextBox 2", 400.0)]); + // Empty shapes of one kind and geometry still pair, and a named text box keeps its partner. + a.shapes.push(shape(3, "Rect A")); + b.shapes.push(shape(4, "Rect B")); + b.shapes[0].name = "TextBox 1".into(); + assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2)), (ShapeId(3), ShapeId(4))]); +} + #[test] fn morph_xfrm_interpolates() { let a = Xfrm { rot: 350.0, ..Xfrm::new(0.0, 0.0, 100.0, 100.0) }; diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 1d50766b..74d246f1 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -24,7 +24,7 @@ three groups, with its Effect Options. All 48 round-trip through PPTX | Duration, advance on click / after time, Apply To All | yes | yes | | Transition sounds | yes | **no** (the model has the field; no picker, no playback) | | Preview in the editor | yes | yes | -| Morph: objects move, resize, recolour | yes | yes | +| Morph: objects move, resize, recolour; unmatched text boxes fade in place | yes | yes (#119) | | Morph: Words and Characters | yes | yes (#5) | | Morph: geometry (circle to star) | yes | yes (#27) | | Morph: `!!` name forcing, pictures, 3-D models, ink | yes | names partly; no 3-D or ink | @@ -76,4 +76,5 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Morph leaves unrelated text boxes unpaired: they fade in place (#119) | | 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates |