diff --git a/crates/ui-egui/src/animpane_tests.rs b/crates/ui-egui/src/animpane_tests.rs index eb147499..c57e1eda 100644 --- a/crates/ui-egui/src/animpane_tests.rs +++ b/crates/ui-egui/src/animpane_tests.rs @@ -162,6 +162,33 @@ fn moving_an_effect_forgets_the_clicked_row() { assert!(at < 0.1, "back to the shape's first effect, not the spin now in that row: {at}"); } +#[test] +fn play_from_skips_a_triggered_effect() { + let mut h = harness(); + // The zoomed shape's first effect becomes triggered by the faded shape; its second effect, + // after the 1 s fade, is still on the click sequence. + let first = h.state().session.active().unwrap().current_slide().unwrap().animations[0].shape.0; + h.state_mut().session.execute("animation.timing", &json!({"index": 1, "trigger": first})).unwrap(); + h.state_mut().session.execute("animation.add", &json!({"effect": "pulse", "start": "afterPrevious", "duration": 1000})).unwrap(); + h.run_steps(2); + let at = play(&mut h, "▶ Play From"); + assert!((1.0..1.1).contains(&at), "from the shape's first effect on the click sequence: {at}"); + // A clicked triggered row falls back the same way. + click_row(&mut h, 2, egui::PointerButton::Primary); + let at = play(&mut h, "▶ Play From"); + assert!((1.0..1.1).contains(&at), "{at}"); +} + +#[test] +fn preview_rejects_a_from_that_is_not_an_effect_index() { + let mut h = harness(); + for from in [json!("1"), json!(-1), json!(1.5)] { + assert!(h.state_mut().run("animation.preview", json!({"from": from})).is_err(), "{from}"); + assert!(h.state().anim_preview_report().is_none(), "{from}"); + } + assert_eq!(h.state_mut().run("animation.preview", json!({"from": 1})).unwrap()["from"], 1.0); +} + #[test] #[ignore = "writes a screenshot for review"] fn screenshot() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b620a049..eb364eab 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -971,7 +971,10 @@ impl SlideApp { self.anim_preview = None; return Ok(json!({"playing": false})); } - let from = p.get("from").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)); + let from = match p.get("from") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().map(|v| usize::try_from(v).unwrap_or(usize::MAX)).ok_or("`from` must be an effect index")?), + }; return self.start_anim_preview(from); } "app.home" => { diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index dc46839f..bd712bb9 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -1022,10 +1022,9 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { // index would point at another effect. let pick_id = egui::Id::new(("anim_pane_pick", st.uid, st.selection.slide)); let pick = ui.data(|m| m.get_temp::(pick_id)); - let from = pick - .filter(|&i| anims.get(i).is_some_and(|a| selected.contains(&a.shape.0))) - .or_else(|| anims.iter().position(|a| selected.contains(&a.shape.0))) - .filter(|&i| tl.start_of(i).is_some()); + // A triggered effect is not on the click sequence, so it can't be played from. + let eligible = |i: usize| anims.get(i).is_some_and(|a| selected.contains(&a.shape.0)) && tl.start_of(i).is_some(); + let from = pick.filter(|&i| eligible(i)).or_else(|| (0..anims.len()).find(|&i| eligible(i))); ui.horizontal(|ui| { let playing = app.anim_preview_active(); let label = match (playing, from) { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 07223c37..7e797ac0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -194,7 +194,7 @@ Features row of the dimension table keeps ~62% until the next full re-measure). | Pictures | 7 | 60% | ~45% | 12–20 | SVG, EMF+ records, clipboard paste on Windows/Linux (#53), remove background, artistic-effect and correction depth | [file-format-parity.md](file-format-parity.md) | | Media | 4 | 67% | ~60% | 8–14 | WMA/WMV decode, record audio/screen, 3-D models and stock content, playback options lost on PPTX round trip (#74) | [file-format-parity.md](file-format-parity.md) | | Transitions | 4 | 100% | ~80% | 4–8 | 3-D transitions approximated in 2-D | [animation-parity.md](animation-parity.md) | -| Animations | 6 | 85% | ~65% | 10–16 | 64 of PowerPoint's ~190 effects, Animation Pane timeline, rewind lost on save (#77) | [animation-parity.md](animation-parity.md) | +| Animations | 6 | 85% | ~65% | 10–16 | 64 of PowerPoint's ~190 effects, rewind lost on save (#77) | [animation-parity.md](animation-parity.md) | | Slide show and presenter view | 8 | 68% | ~60% | 10–16 | Second-display placement, laser from presenter view, recording, subtitles, ink depth | [slideshow-parity.md](slideshow-parity.md) | | Review | 3 | 70% | ~45% | 8–14 | Compare depth, version history, real spelling dictionaries | [file-format-parity.md](file-format-parity.md) | | Draw (ink) | 2 | 50% | ~30% | 5–9 | Pen tilt, ink to math, lasso, ink to shape/text depth | [hardware-parity.md](hardware-parity.md) |