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
This commit is contained in:
Alexander Alber
2026-10-11 09:03:01 +02:00
parent a98df49b21
commit 04069e1968
3 changed files with 27 additions and 3 deletions
+5 -2
View File
@@ -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 {
+20
View File
@@ -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) };
+2 -1
View File
@@ -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 |