//! Animations tab and Animation Pane. use std::sync::Arc; use deckcraft_model::anim::{ANIMATIONS, AnimClass, AnimStart, Animation, TextBuild, animation_info}; use serde_json::{Value, json}; use super::{ CommandSpec, always, bad, bool_or, bool_param, cmd, has_selection, has_slide, id_param, ok, opt_usize_param, str_param, targets, usize_param, }; use crate::{Result, Session}; pub fn specs() -> Vec { vec![ cmd!( "animation.set", "Animation Styles", ["Animations", "Animation"], None, "{effect: fade|fly|zoom|wipe|appear|spin|pulse|fadeOut|lines|… (animation.list), class?: entrance|emphasis|exit|path, option?, path?: [[x,y],…] or OOXML string, closed?: bool, ids?} — replaces the shapes' animations. path (motion paths): points in slide points, as offsets from where the shape sits, or the OOXML path string (slide fractions) animation.get answers", has_selection, set ), cmd!( "animation.add", "Add Animation", ["Animations", "Advanced Animation"], None, "{effect, class?, option?, path?: [[x,y],…] or OOXML string, closed?: bool, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?} → {count, path?}", has_selection, add ), cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids? (all of the shapes' animations)}", has_slide, remove), cmd!("animation.move", "Reorder Animation", ["Animations", "Timing"], None, "{index, to}", has_slide, move_anim), cmd!( "animation.timing", "Timing", ["Animations", "Timing"], None, "{index? (else selected shapes), start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, repeat?: n, rewind?: bool, trigger?: shape id | null}", has_slide, timing ), cmd!( "animation.options", "Effect Options", ["Animations", "Animation"], None, "{option?, textBuild?: asOne|byParagraph|byWord|byLetter, path?: [[x,y],…] or OOXML string, closed?: bool, index?}", has_slide, 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), ] } fn class_of(s: Option<&str>) -> Option { s.map(|c| match c { "emphasis" | "emph" => AnimClass::Emphasis, "exit" => AnimClass::Exit, "path" | "motion" | "motionPath" => AnimClass::Path, "media" => AnimClass::Media, _ => AnimClass::Entrance, }) } fn start_of(s: &str) -> AnimStart { match s { "withPrevious" | "with" => AnimStart::WithPrevious, "afterPrevious" | "after" => AnimStart::AfterPrevious, _ => AnimStart::OnClick, } } /// The effect option `given` as one of `allowed`. Besides the stored codes ("b", "lt", "u", …) /// it takes the direction words people type: "fromBottom", "bottom", "fromTopLeft", /// "upRight". `None` when it matches none of them: the renderer reads direction codes letter /// by letter, so an unchecked "fromBottom" would fly in from the right and the bottom. fn resolve_option(allowed: &[&str], given: &str) -> Option { if allowed.is_empty() || allowed.contains(&given) { return Some(given.to_string()); } let low = given.to_ascii_lowercase(); if let Some(o) = allowed.iter().find(|o| o.to_ascii_lowercase() == low) { return Some(o.to_string()); } let mut rest = low.strip_prefix("from").unwrap_or(&low).trim_start_matches(['-', '_', ' ']); // Direction words in order, each as both its edge letter and its arrow letter. let mut dirs: Vec<(char, char)> = Vec::new(); while !rest.is_empty() { let words: [(&str, (char, char)); 8] = [ ("bottom", ('b', 'd')), ("down", ('b', 'd')), ("top", ('t', 'u')), ("up", ('t', 'u')), ("left", ('l', 'l')), ("right", ('r', 'r')), ("-", ('-', '-')), ("_", ('-', '-')), ]; let (w, d) = words.iter().find(|(w, _)| rest.starts_with(w))?; if d.0 != '-' { dirs.push(*d); } rest = &rest[w.len()..]; } if dirs.is_empty() || dirs.len() > 2 { return None; } let mut cands: Vec = Vec::new(); for pick in [|d: &(char, char)| d.0, |d: &(char, char)| d.1] { let code: String = dirs.iter().map(pick).collect(); let rev: String = code.chars().rev().collect(); cands.push(code); cands.push(rev); } cands.into_iter().find(|c| allowed.contains(&c.as_str())) } fn build(p: &Value, slide: (f64, f64)) -> Result { let effect = str_param(p, "effect").ok_or_else(|| bad("animation", "missing `effect`"))?; let class = class_of(str_param(p, "class")); let info = match class { Some(c) => animation_info(effect, c), None => ANIMATIONS.iter().find(|a| a.0 == effect), } .ok_or_else(|| bad("animation", format!("unknown effect `{effect}`")))?; let mut a = Animation { class: info.2, effect: info.0.to_string(), option: match str_param(p, "option") { Some(o) => { resolve_option(info.5, o).ok_or_else(|| bad("animation", format!("option `{o}` is not one of {:?} for `{}`", info.5, info.0)))? } None => info.5.first().map(|o| o.to_string()).unwrap_or_default(), }, duration_ms: p.get("duration").and_then(Value::as_u64).map(|v| v.min(600_000) as u32).unwrap_or(info.4), delay_ms: p.get("delay").and_then(Value::as_u64).map(|v| v.min(600_000) as u32).unwrap_or(0), preset_id: Some(info.3), ..Default::default() }; if let Some(st) = str_param(p, "start") { a.start = start_of(st); } if a.class == AnimClass::Path { a.path = Some(path_param(p, slide)?.unwrap_or_else(|| default_path(&a.effect, &a.option))); } else if p.get("path").is_some() { return Err(bad("animation", "`path` only fits a motion path (class `path`)")); } if a.effect == "spin" { a.amount = Some(360.0); } if a.effect == "growShrink" { a.amount = Some(1.5); } Ok(a) } /// Most points of a motion path given as an array, and longest path string given as text. const MAX_PATH_POINTS: usize = 1_000; const MAX_PATH_TEXT: usize = 16 * 1024; /// Largest offset of a point, in slide points; anything beyond is not a place on any slide. const MAX_PATH_OFFSET: f64 = 1.0e6; /// A number of a path string: slide fractions, rounded to 6 places. fn path_num(v: f64) -> String { let t = format!("{:.6}", v); let t = t.trim_end_matches('0').trim_end_matches('.'); if t == "-0" || t.is_empty() { "0".into() } else { t.into() } } /// Does a raw motion path string draw: only commands and numbers, every number finite, and at /// least two points? (A lone `e`, or `1e999`, would play as an empty or partial path.) fn raw_path_draws(text: &str) -> bool { let cs: Vec = text.chars().collect(); let (mut i, mut nums) = (0, 0usize); while let Some(&c) = cs.get(i) { if c.is_ascii_digit() || c == '.' || ((c == '-' || c == '+') && cs.get(i + 1).is_some_and(|d| d.is_ascii_digit() || *d == '.')) { let start = i; i += 1; while cs.get(i).is_some_and(|d| d.is_ascii_digit() || *d == '.') { i += 1; } if cs.get(i).is_some_and(|d| *d == 'e' || *d == 'E') { let n1 = cs.get(i + 1).copied().unwrap_or(' '); let n2 = cs.get(i + 2).copied().unwrap_or(' '); if n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit()) { i += 2; while cs.get(i).is_some_and(char::is_ascii_digit) { i += 1; } } } let tok: String = cs.get(start..i).map(|t| t.iter().collect()).unwrap_or_default(); match tok.parse::() { Ok(v) if v.is_finite() => nums += 1, _ => return false, } } else if " ,MmLlCcQqHhVvZzE".contains(c) { i += 1; } else { return false; } } nums >= 4 } /// The motion path `path` asks for, as the OOXML path string animations store: either points /// (`[[x, y], …]`, slide points as offsets from where the shape sits, joined by lines, `closed?` /// to return to the first) or that string itself. fn path_param(p: &Value, slide: (f64, f64)) -> Result> { let Some(v) = p.get("path") else { return Ok(None) }; let refuse = |why: &str| bad("animation", format!("`path` {why}")); match v { Value::String(text) => { let ok = text.len() <= MAX_PATH_TEXT && text.contains(|c: char| c.is_ascii_digit()) && text.chars().all(|c| c.is_ascii_digit() || " .+-eE,MmLlCcQqHhVvZz".contains(c)) && raw_path_draws(text); if !ok { return Err(refuse("string must be an OOXML motion path such as `M 0 0 L 0.25 0 E` (at most 16 KiB)")); } Ok(Some(text.clone())) } Value::Array(points) => { if !(2..=MAX_PATH_POINTS).contains(&points.len()) { return Err(refuse(&format!("needs 2 to {MAX_PATH_POINTS} points"))); } if !(slide.0.is_finite() && slide.1.is_finite() && slide.0 > 0.0 && slide.1 > 0.0) { return Err(refuse("can't be placed: the slide has no size")); } let mut out = String::new(); for (k, pt) in points.iter().enumerate() { let xy = pt.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))); let Some((x, y)) = xy.filter(|(x, y)| x.abs() <= MAX_PATH_OFFSET && y.abs() <= MAX_PATH_OFFSET) else { return Err(refuse(&format!("point {k} must be [x, y], two finite numbers within ±{MAX_PATH_OFFSET} points"))); }; out.push_str(&format!("{} {} {} ", if k == 0 { "M" } else { "L" }, path_num(x / slide.0), path_num(y / slide.1))); } if p.get("closed").and_then(Value::as_bool) == Some(true) { out.push_str("Z "); } out.push('E'); Ok(Some(out)) } _ => Err(refuse("must be [[x, y], …] or an OOXML path string")), } } /// Default motion paths, in slide-fraction units relative to the shape's position. pub fn default_path(effect: &str, option: &str) -> String { let (dx, dy) = match option { "up" => (0.0, -0.25), "right" => (0.25, 0.0), "left" => (-0.25, 0.0), _ => (0.0, 0.25), }; match effect { "arcs" => format!("M 0 0 C {} {} {} {} {dx} {dy} E", dx * 0.1 + dy * 0.5, dy * 0.1 - dx * 0.5, dx * 0.9 + dy * 0.5, dy * 0.9 - dx * 0.5), "turns" => format!("M 0 0 L {} {} C {} {} {dx} {} {dx} {dy} E", dx * 0.5, dy * 0.5, dx, dy * 0.5, dy * 0.75), "shapes" => "M 0 0 C 0.1 0 0.1 0.15 0 0.15 C -0.1 0.15 -0.1 0 0 0 Z E".into(), "loops" => "M 0 0 C 0.1 -0.1 0.2 0.1 0.1 0.1 C 0 0.1 0.1 -0.1 0.25 0 E".into(), other => deckcraft_model::anim::preset_path(other).unwrap_or_else(|| format!("M 0 0 L {dx} {dy} E")), } } fn set(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; let a = build(p, slide_extent(s)?)?; s.edit(|doc, sel| { let sl = doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.set", "no slide"))?; let sl = Arc::make_mut(sl); // Replace each shape's animations, keeping their place in the sequence. let mut out = vec![]; let mut placed = vec![]; for x in sl.animations.drain(..) { if ids.contains(&x.shape) { if !placed.contains(&x.shape) { placed.push(x.shape); out.push(Animation { shape: x.shape, start: x.start, ..a.clone() }); } } else { out.push(x); } } for id in &ids { if !placed.contains(id) { out.push(Animation { shape: *id, ..a.clone() }); } } if a.effect == "none" { out.retain(|x| !ids.contains(&x.shape)); } sl.animations = out; Ok(json!({"count": sl.animations.len()})) }) } /// The slide's width and height in points. fn slide_extent(s: &mut Session) -> Result<(f64, f64)> { let size = &s.doc()?.doc.slide_size; Ok((size.width, size.height)) } fn add(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; let a = build(p, slide_extent(s)?)?; let path = a.path.clone(); s.edit(|doc, sel| { let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.add", "no slide"))?); for (k, id) in ids.iter().enumerate() { let mut x = Animation { shape: *id, ..a.clone() }; if k > 0 { x.start = AnimStart::WithPrevious; } sl.animations.push(x); } Ok(match &path { Some(path) => json!({"count": sl.animations.len(), "path": path}), None => json!({"count": sl.animations.len()}), }) }) } fn remove(s: &mut Session, p: &Value) -> Result { let index = opt_usize_param(p, "index", "animation.remove")?; let ids = if index.is_none() { targets(s, p)? } else { vec![] }; s.edit(|doc, sel| { let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.remove", "no slide"))?); match index { Some(i) if i < sl.animations.len() => { sl.animations.remove(i); } Some(_) => return Err(bad("animation.remove", "no such animation")), None => sl.animations.retain(|a| !ids.contains(&a.shape)), } Ok(()) })?; ok() } fn move_anim(s: &mut Session, p: &Value) -> Result { let i = opt_usize_param(p, "index", "animation.move")?.ok_or_else(|| bad("animation.move", "missing `index`"))?; let to = usize_param(p, "to").ok_or_else(|| bad("animation.move", "missing `to`"))?; s.edit(|doc, sel| { let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.move", "no slide"))?); if i >= sl.animations.len() { return Err(bad("animation.move", "no such animation")); } let a = sl.animations.remove(i); let to = to.min(sl.animations.len()); sl.animations.insert(to, a); Ok(()) })?; ok() } fn each_target(s: &mut Session, p: &Value, f: impl Fn(&mut Animation)) -> Result { let index = opt_usize_param(p, "index", "animation")?; let ids = if index.is_none() { targets(s, p)? } else { vec![] }; s.edit(|doc, sel| { let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation", "no slide"))?); let mut n = 0; for (k, a) in sl.animations.iter_mut().enumerate() { if index == Some(k) || (index.is_none() && ids.contains(&a.shape)) { f(a); n += 1; } } if n == 0 { return Err(bad("animation", "no animation to change (select an animated object or give `index`)")); } Ok(json!({"changed": n})) }) } fn timing(s: &mut Session, p: &Value) -> Result { let start = str_param(p, "start").map(start_of); let dur = p.get("duration").and_then(Value::as_u64).map(|v| v.min(600_000) as u32); let delay = p.get("delay").and_then(Value::as_u64).map(|v| v.min(600_000) as u32); let repeat = p.get("repeat").and_then(Value::as_u64).map(|v| v.min(u32::MAX as u64) as u32); let rewind = bool_param(p, "rewind"); let trigger = p.get("trigger").map(|v| v.as_u64().and_then(|x| u32::try_from(x).ok()).map(deckcraft_model::ShapeId)); each_target(s, p, |a| { if let Some(x) = start { a.start = x; } if let Some(x) = dur { a.duration_ms = x; } if let Some(x) = delay { a.delay_ms = x; } if let Some(x) = repeat { a.repeat = x; } if let Some(x) = rewind { a.rewind = x; } if let Some(t) = trigger { a.trigger = t; } }) } fn options(s: &mut Session, p: &Value) -> Result { let option = str_param(p, "option").map(String::from); let build_mode = str_param(p, "textBuild").map(|b| match b { "byParagraph" => TextBuild::ByParagraph, "byWord" => TextBuild::ByWord, "byLetter" => TextBuild::ByLetter, _ => TextBuild::AsOne, }); let path = path_param(p, slide_extent(s)?)?; each_target(s, p, |a| { if let (Some(path), AnimClass::Path) = (&path, a.class) { a.path = Some(path.clone()); } if let Some(o) = &option { // Each target takes the option in its own effect's terms; one it has no such // option for keeps its own. let allowed = animation_info(&a.effect, a.class).map(|i| i.5).unwrap_or(&[]); if let Some(o) = resolve_option(allowed, o) { if a.class == AnimClass::Path && path.is_none() { a.path = Some(default_path(&a.effect, &o)); } a.option = o; } } if let Some(b) = build_mode { a.text_build = b; } }) } fn clear(s: &mut Session, p: &Value) -> Result { let i = match opt_usize_param(p, "index", "animation.clear")? { Some(i) => i, None => s.doc()?.selection.slide, }; s.edit(|doc, _| { if let Some(sl) = doc.slides.get_mut(i) { Arc::make_mut(sl).animations.clear(); } Ok(()) })?; ok() } fn list(_s: &mut Session, _p: &Value) -> Result { Ok(Value::Array(ANIMATIONS.iter().map(|a| json!({"id": a.0, "label": a.1, "class": a.2.xml(), "options": a.5})).collect())) } fn get(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let i = usize_param(p, "slide").unwrap_or(st.selection.slide); 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", "animation.painter")? .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::resolve_option; #[test] fn direction_words_resolve_to_codes() { let fly = &["b", "lb", "l", "lt", "t", "rt", "r", "rb"][..]; assert_eq!(resolve_option(fly, "fromBottom").as_deref(), Some("b")); assert_eq!(resolve_option(fly, "bottom").as_deref(), Some("b")); assert_eq!(resolve_option(fly, "fromTopLeft").as_deref(), Some("lt")); assert_eq!(resolve_option(fly, "fromBottomRight").as_deref(), Some("rb")); assert_eq!(resolve_option(fly, "lt").as_deref(), Some("lt")); assert_eq!(resolve_option(fly, "sideways"), None); assert_eq!(resolve_option(fly, "fromBottomTopLeft"), None); let wipe = &["r", "l", "u", "d", "ru", "lu", "rd", "ld"][..]; assert_eq!(resolve_option(wipe, "fromBottom").as_deref(), Some("d")); assert_eq!(resolve_option(wipe, "upRight").as_deref(), Some("ru")); let split = &["horzIn", "horzOut", "vertIn", "vertOut"][..]; assert_eq!(resolve_option(split, "HorzIn").as_deref(), Some("horzIn")); assert_eq!(resolve_option(&[], "anything").as_deref(), Some("anything")); } use super::*; #[test] fn clear_rejects_invalid_index() { let mut s = Session::with_new(); let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap(); let id = r.get("id").unwrap().as_u64().unwrap(); s.execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id]})).unwrap(); for bad_index in [json!(-1), json!(1.5)] { assert!(s.execute("animation.clear", &json!({"index": bad_index})).is_err()); let left = s.execute("animation.get", &json!({})).unwrap(); assert_eq!(left.as_array().unwrap().len(), 1); } } #[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()); } fn setup_triggers() -> (Session, [u64; 4]) { let mut s = Session::with_new(); let mut ids = [0u64; 4]; for (slot, x) in ids.iter_mut().zip([10.0, 150.0, 300.0, 450.0]) { let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10.0, 100.0, 100.0]})).unwrap(); *slot = r.get("id").unwrap().as_u64().unwrap(); } (s, ids) } fn triggers(s: &Session) -> Vec> { s.doc().unwrap().current_slide().unwrap().animations.iter().map(|x| x.trigger).collect() } #[test] fn deleting_a_trigger_shape_clears_the_trigger() { let (mut s, [a, b, c, d]) = setup_triggers(); let id = |v: u64| Some(deckcraft_model::ShapeId(v as u32)); s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap(); s.execute("animation.add", &json!({"effect": "fade", "ids": [d]})).unwrap(); s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap(); s.execute("animation.timing", &json!({"index": 1, "trigger": c})).unwrap(); assert_eq!(triggers(&s), vec![id(b), id(c)]); s.execute("edit.delete", &json!({"ids": [b]})).unwrap(); assert_eq!(triggers(&s), vec![None, id(c)], "only the deleted trigger is cleared"); assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 2, "the animated shapes keep their effects"); } #[test] fn deleting_a_group_clears_triggers_of_its_children() { let (mut s, [a, b, c, _]) = setup_triggers(); let g = s.execute("arrange.group", &json!({"ids": [b, c]})).unwrap().get("id").unwrap().as_u64().unwrap(); s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap(); s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap(); assert_eq!(triggers(&s), vec![Some(deckcraft_model::ShapeId(b as u32))]); s.execute("edit.delete", &json!({"ids": [g]})).unwrap(); assert_eq!(triggers(&s), vec![None], "the trigger was inside the deleted group"); assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 1); } #[test] fn invalid_index_is_an_error_not_the_selection() { let mut s = Session::with_new(); let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap(); let id = r.get("id").unwrap().as_u64().unwrap(); s.execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id]})).unwrap(); s.select(|_, sel| { sel.shapes = vec![deckcraft_model::ShapeId(id as u32)]; }) .unwrap(); for bad_index in [json!(-1), json!(1.5), json!("x")] { for cmd in ["animation.remove", "animation.timing", "animation.options"] { let r = s.execute(cmd, &json!({"index": bad_index, "duration": 100})); assert!(r.is_err(), "{cmd} accepted index {bad_index}: {r:?}"); } let r = s.execute("animation.move", &json!({"index": bad_index, "to": 0})); assert!(r.is_err(), "animation.move accepted index {bad_index}: {r:?}"); assert_eq!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().len(), 1); } } fn path_of(s: &mut Session) -> Option { let all = s.execute("animation.get", &json!({})).unwrap(); all.as_array().unwrap().last().and_then(|a| a.get("path")).and_then(Value::as_str).map(String::from) } fn shape(s: &mut Session) -> u64 { s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100.0, 400.0, 20.0, 20.0]})).unwrap().get("id").unwrap().as_u64().unwrap() } /// #1576: points in slide points, as offsets from the shape, become the slide-fraction path. #[test] fn a_custom_path_takes_its_points() { let mut s = Session::with_new(); let id = shape(&mut s); let w = s.doc().unwrap().doc.slide_size.width; let h = s.doc().unwrap().doc.slide_size.height; let r = s .execute( "animation.add", &json!({"effect": "customPath", "class": "path", "path": [[0, 0], [w / 2.0, -h / 4.0], [w, -h / 2.0]], "ids": [id]}), ) .unwrap(); assert_eq!(r["path"], "M 0 0 L 0.5 -0.25 L 1 -0.5 E"); assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.5 -0.25 L 1 -0.5 E")); // closed shapes end back at the start; set replaces; no path keeps the default s.execute("animation.set", &json!({"effect": "customPath", "path": [[0, 0], [w / 4.0, 0], [w / 4.0, h / 4.0]], "closed": true, "ids": [id]})) .unwrap(); assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.25 0 L 0.25 0.25 Z E")); s.execute("animation.set", &json!({"effect": "customPath", "ids": [id]})).unwrap(); assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0 0.25 E")); } #[test] fn a_raw_path_string_is_kept_and_options_edits_it() { let mut s = Session::with_new(); let id = shape(&mut s); s.execute("animation.add", &json!({"effect": "customPath", "path": "M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E", "ids": [id]})).unwrap(); assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E")); s.execute("animation.options", &json!({"index": 0, "path": [[0, 0], [96, 54]]})).unwrap(); assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.1 0.1 E")); } #[test] fn a_bad_path_is_refused_and_changes_nothing() { let mut s = Session::with_new(); let id = shape(&mut s); let many: Vec = (0..2_000).map(|i| json!([i, i])).collect(); for bad_path in [ json!([[0, 0]]), json!([]), json!([[0, 0], [1]]), json!([[0, 0], ["a", 1]]), json!([[0, 0], [1e300, 1]]), json!([[0, 0], [null, 1]]), json!("M 0 0 L