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:
parent
7f1e152870
commit
8fe168c07b
|
|
@ -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%**.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue