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 <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paulo Ravazzano 2026-10-10 07:24:23 -03:00 committed by GitHub
parent 7f1e152870
commit 8fe168c07b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 189 additions and 5 deletions

View File

@ -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%**.

View File

@ -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

View File

@ -49,6 +49,16 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<Value> {
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<Animation> = 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<Value> {
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());
}
}

View File

@ -336,6 +336,8 @@ fn select_all(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn deselect(s: &mut Session, _p: &Value) -> Result<Value> {
s.painter = None;
s.anim_painter = None;
s.select(|_, sel| {
if let Some(t) = sel.text.take() {
// Esc while editing text selects the shape.

View File

@ -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<deckcraft_model::anim::Animation>, bool)>,
/// Set as Default Shape: the look new shapes get.
pub default_look: Option<cmd::format::Painted>,
/// 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(),

View File

@ -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;

View File

@ -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| {

View File

@ -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.
<!-- SUMMARY -->
**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 |