From 8fe168c07b75b324bbf5faa8d4c4ccf4aa4334db Mon Sep 17 00:00:00 2001 From: Paulo Ravazzano <226770400+Johannesgauss@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:24:23 -0300 Subject: [PATCH] Animation Painter: copy and apply animation sequences between shapes (#54) - engine: add animation.painter (Alt+Shift+C) and animation.painterApply (Alt+Shift+V) supporting single-use and sticky modes - tools: apply animation painter on canvas shape click - edit: disarm animation painter on deselect (Escape) - ui-egui: add Animation Painter button in Advanced Animation ribbon group - tests: unit tests for single and sticky animation painting - docs/parity: mark Animation Painter as done (breadth parity to 80%) - contributors: add Johannesgauss to contributors/people.toml Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 +- contributors/people.toml | 3 + crates/engine/src/cmd/animation.rs | 159 +++++++++++++++++++++++++++++ crates/engine/src/cmd/edit.rs | 2 + crates/engine/src/lib.rs | 3 + crates/engine/src/tools.rs | 4 + crates/ui-egui/src/ribbon.rs | 13 +++ docs/parity.md | 8 +- 8 files changed, 189 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d3a31fb..86e5cd4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -80,7 +80,7 @@ AutoRecover, CLI/MCP automation, web build. ## How far from full parity (estimate, 2026-10-07) -**Breadth: ~79% weighted** (P0 core 93%, P1 74%, P2 35%) over the 187 features of the PowerPoint catalogue, scored row by +**Breadth: ~80% weighted** (P0 core 93%, P1 74%, P2 37%) over the 187 features of the PowerPoint catalogue, scored row by row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored done still lack some of PowerPoint's options, dialogs or pixel fidelity, so **overall parity including depth is about 62%**. diff --git a/contributors/people.toml b/contributors/people.toml index 0bd95f6..b852df1 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -34,3 +34,6 @@ sync_github_name = false real_name = "Dmytro Smaha" display_name = "Dima Smaha" sync_github_name = false + +[people.Johannesgauss] +sync_github_name = true diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index e6844f5..8b6fe5a 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -49,6 +49,16 @@ pub fn specs() -> Vec { options ), cmd!("animation.clear", "Remove All Animations", [], None, "{index?: slide}", has_slide, clear), + cmd!( + noundo "animation.painter", + "Animation Painter", + ["Animations", "Advanced Animation"], + Some("Alt+Shift+C"), + "{sticky?: bool, id?}", + has_selection, + painter_pick + ), + cmd!("animation.painterApply", "Apply Animation", [], Some("Alt+Shift+V"), "{ids?}", has_selection, painter_apply), cmd!(query "animation.list", "Animation Gallery", [], None, "{} → [{id, label, class, options}]", always, list), cmd!(query "animation.get", "Animations on Slide", [], None, "{slide?} → [animation]", has_slide, get), ] @@ -292,3 +302,152 @@ fn get(s: &mut Session, p: &Value) -> Result { let sl = st.doc.slides.get(i).ok_or_else(|| bad("animation.get", "no such slide"))?; Ok(serde_json::to_value(&sl.animations).unwrap_or_default()) } + +fn painter_pick(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let id = id_param(p, "id") + .or_else(|| st.selection.shapes.first().copied()) + .ok_or_else(|| bad("animation.painter", "select an animated shape first"))?; + let sl = st.current_slide().ok_or_else(|| bad("animation.painter", "no slide"))?; + let anims: Vec = sl.animations.iter().filter(|a| a.shape == id).cloned().collect(); + if anims.is_empty() { + return Err(bad("animation.painter", format!("shape {id} has no animations to copy"))); + } + let sticky = bool_or(p, "sticky", false); + let count = anims.len(); + s.anim_painter = Some((anims, sticky)); + Ok(json!({"armed": true, "count": count})) +} + +fn painter_apply(s: &mut Session, p: &Value) -> Result { + let Some((painted, sticky)) = s.anim_painter.clone() else { + return Err(bad("animation.painterApply", "pick animations with the Animation Painter first")); + }; + if !sticky { + s.anim_painter = None; + } + let ids = targets(s, p)?; + if ids.is_empty() { + return Err(bad("animation.painterApply", "no target shapes selected")); + } + s.edit(|doc, sel| { + let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.painterApply", "no slide"))?); + // As in PowerPoint, painting replaces the target's own animations (painting a shape onto + // itself doesn't double them). A paragraph effect keeps its paragraph only if the target + // has that paragraph; otherwise it animates the whole shape. + sl.animations.retain(|a| !ids.contains(&a.shape)); + let mut added = 0; + for id in &ids { + let paras = deckcraft_model::find_shape(&sl.shapes, *id).and_then(|sh| sh.text.as_ref()).map_or(0, |t| t.paragraphs.len()); + for a in &painted { + let mut cloned = a.clone(); + cloned.shape = *id; + if cloned.paragraph.is_some_and(|i| i as usize >= paras) { + cloned.paragraph = None; + } + sl.animations.push(cloned); + added += 1; + } + } + Ok(json!({"applied": added})) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn animation_painter_copies_effects_between_shapes() { + let mut s = Session::with_new(); + // Insert two shapes + let s1 = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap(); + let id1 = s1.get("id").unwrap().as_u64().unwrap(); + let s2 = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [150.0, 10.0, 100.0, 100.0]})).unwrap(); + let id2 = s2.get("id").unwrap().as_u64().unwrap(); + + // Add 2 animations to shape 1: fade (entrance) and spin (emphasis) + s.execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id1]})).unwrap(); + s.execute("animation.add", &json!({"effect": "spin", "class": "emphasis", "ids": [id1]})).unwrap(); + + // Shape 2 initially has no animations + let anims_before = s.execute("animation.get", &json!({})).unwrap(); + let arr_before = anims_before.as_array().unwrap(); + assert_eq!(arr_before.len(), 2); + assert!(arr_before.iter().all(|a| a.get("shape").unwrap().as_u64().unwrap() == id1)); + + // Pick animations from shape 1 + s.select(|_, sel| { + sel.shapes = vec![deckcraft_model::ShapeId(id1 as u32)]; + }) + .unwrap(); + let res = s.execute("animation.painter", &json!({})).unwrap(); + assert_eq!(res.get("armed").unwrap().as_bool(), Some(true)); + assert_eq!(res.get("count").unwrap().as_u64(), Some(2)); + assert!(s.anim_painter.is_some()); + + // Apply animations to shape 2 + s.select(|_, sel| { + sel.shapes = vec![deckcraft_model::ShapeId(id2 as u32)]; + }) + .unwrap(); + let applied = s.execute("animation.painterApply", &json!({})).unwrap(); + assert_eq!(applied.get("applied").unwrap().as_u64(), Some(2)); + + // Single-click should disarm the painter + assert!(s.anim_painter.is_none()); + + // Painting again replaces shape 2's animations instead of adding to them; undo restores. + s.execute("animation.painter", &json!({"id": id1})).unwrap(); + s.execute("animation.painterApply", &json!({"ids": [id2]})).unwrap(); + let count_on = |s: &mut Session, id: u64| { + let all = s.execute("animation.get", &json!({})).unwrap(); + all.as_array().unwrap().iter().filter(|a| a.get("shape").unwrap().as_u64().unwrap() == id).count() + }; + assert_eq!(count_on(&mut s, id2), 2); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(count_on(&mut s, id2), 2, "undo restores the first painting"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(count_on(&mut s, id2), 0, "undo removes the painted animations"); + s.execute("edit.redo", &json!({})).unwrap(); + + // Verify shape 2 now has the 2 animations cloned + let anims_after = s.execute("animation.get", &json!({})).unwrap(); + let arr_after = anims_after.as_array().unwrap(); + assert_eq!(arr_after.len(), 4); + let s2_anims: Vec<_> = arr_after.iter().filter(|a| a.get("shape").unwrap().as_u64().unwrap() == id2).collect(); + assert_eq!(s2_anims.len(), 2); + assert_eq!(s2_anims[0].get("effect").unwrap().as_str(), Some("fade")); + assert_eq!(s2_anims[1].get("effect").unwrap().as_str(), Some("spin")); + + // Picking from shape with no animations returns an error + s.execute("animation.clear", &json!({})).unwrap(); + let err = s.execute("animation.painter", &json!({"id": id1})); + assert!(err.is_err()); + } + + #[test] + fn animation_painter_sticky_mode() { + let mut s = Session::with_new(); + let s1 = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap(); + let id1 = s1.get("id").unwrap().as_u64().unwrap(); + let s2 = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [150.0, 10.0, 100.0, 100.0]})).unwrap(); + let id2 = s2.get("id").unwrap().as_u64().unwrap(); + let s3 = s.execute("shape.insert", &json!({"preset": "roundRect", "rect": [300.0, 10.0, 100.0, 100.0]})).unwrap(); + let id3 = s3.get("id").unwrap().as_u64().unwrap(); + + s.execute("animation.add", &json!({"effect": "zoom", "class": "entrance", "ids": [id1]})).unwrap(); + + // Pick with sticky = true + s.execute("animation.painter", &json!({"id": id1, "sticky": true})).unwrap(); + assert!(s.anim_painter.is_some()); + + // Apply to shape 2 - painter remains armed + s.execute("animation.painterApply", &json!({"ids": [id2]})).unwrap(); + assert!(s.anim_painter.is_some()); + + // Apply to shape 3 - painter still armed + s.execute("animation.painterApply", &json!({"ids": [id3]})).unwrap(); + assert!(s.anim_painter.is_some()); + } +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index dfcbec6..30437b1 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -336,6 +336,8 @@ fn select_all(s: &mut Session, _p: &Value) -> Result { } fn deselect(s: &mut Session, _p: &Value) -> Result { + s.painter = None; + s.anim_painter = None; s.select(|_, sel| { if let Some(t) = sel.text.take() { // Esc while editing text selects the shape. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d87653d..a2d2d2a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -300,6 +300,8 @@ pub struct Session { pub(crate) untitled: u32, /// Format Painter: copied shape/text formatting and whether it stays on (double-click). pub painter: Option<(cmd::format::Painted, bool)>, + /// Animation Painter: copied shape animations and whether it stays on (double-click). + pub anim_painter: Option<(Vec, bool)>, /// Set as Default Shape: the look new shapes get. pub default_look: Option, /// Nesting of commands run by commands: only the outermost one is an undo step and a journal @@ -333,6 +335,7 @@ impl Session { untitled: 0, default_look: None, painter: None, + anim_painter: None, depth: 0, recovery_dir: None, media_status: Default::default(), diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index b60e20a..9a33933 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -434,6 +434,10 @@ impl Session { if self.painter.is_some() && !toggle { let _ = self.execute("format.painterApply", &json!({"ids": [id]})); } + // Animation Painter applies on click. + if self.anim_painter.is_some() && !toggle { + let _ = self.execute("animation.painterApply", &json!({"ids": [id]})); + } } None => { let add = mods.shift || mods.cmd; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 32e09e8..c7c251e 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1627,6 +1627,19 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { } } }); + let on = app.session.anim_painter.is_some(); + let en = enabled(app, "animation.painter"); + let (r, _) = big_button_ex(ui, Icon::AnimationPainter, "Animation\nPainter", en, false, on); + let r = r.on_hover_text("Animation Painter (double-click to keep it on)"); + if r.double_clicked() { + run(app, "animation.painter", json!({"sticky": true})); + } else if r.clicked() { + if on { + app.session.anim_painter = None; + } else { + run(app, "animation.painter", json!({})); + } + } }); group(ui, |ui| { rows(ui, |ui| { diff --git a/docs/parity.md b/docs/parity.md index 8c3b09b..e460002 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,13 +11,13 @@ Each row is scored **D** (done), **P** (partial: works but lacks options, UI or Live Presentations), add-ins and VBA macros. -**Weighted breadth parity: 79%** over 187 features. +**Weighted breadth parity: 80%** over 187 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 72 | 62 | 10 | 0 | 93% | | P1 | 65 | 37 | 22 | 6 | 74% | -| P2 | 50 | 12 | 11 | 27 | 35% | +| P2 | 50 | 13 | 11 | 26 | 37% | | Area | Parity | |---|---| @@ -36,7 +36,7 @@ Live Presentations), add-ins and VBA macros. | Pictures | 58% | | Media | 68% | | Transitions | 83% | -| Animations | 74% | +| Animations | 79% | | Slide show | 66% | | Review | 60% | | Draw | 33% | @@ -211,7 +211,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh | Animations | Animation pane (reorder, timing) | P0 | P | | Animations | By-paragraph text builds | P1 | D | | Animations | Triggers | P2 | D | -| Animations | Animation Painter | P2 | M | +| Animations | Animation Painter | P2 | D | | Animations | Preview in editor | P1 | P | | Slide show | Full-screen show from start / current | P0 | D | | Slide show | Navigation (keys, click, go to slide, blank screen) | P0 | D |