Browse / github.com/storytold/deckcraft / patch
Animations: Anvil, the Keynote-style entrance
Animations: Anvil, the Keynote-style entrance ## What A new entrance effect, **Anvil**, the one Keynote users know and miss in PowerPoint. The shape free-falls from above the slide, squashes on landing (its base stays on the ground), springs back, and kicks up a puff of dust that billows out sideways. It's in the Animations ribbon's quick gallery next to Zoom, and in the full gallery and `animation.list`, like any other entrance. ## How - **anim** (`effects.rs`, `lib.rs`): the `anvil` entrance. A gravity drop (distance ∝ t²) over the first 45 % of the effect, then a squash that rings out (`scale_y` down, `scale_x` up, offset so the
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- SimonGadgeteer (imported from pull request #1045, open)
- Base
f34f4121c8ee720ca08e5d47b912155a33504e69- Size
- +182 −4 in 11 files
- Review
- pending
- Shared
- 2026-10-28
- Id
2ca8e2eefa28e430e6432696
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/anim/src/effects.rs crates/anim/src/lib.rs crates/anim/src/tests.rs crates/model/src/anim.rs crates/pptx/src/write/timing.rs crates/render/src/lib.rs crates/ui-egui/src/preview.rs crates/ui-egui/src/ribbon.rs crates/ui-egui/src/show.rs docs/animation-parity.md
@@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---|+| 2026-10-19 | Animations: Anvil, the Keynote-style entrance. The shape drops from above the slide, squashes on landing and kicks up a puff of dust; in the quick gallery. PPTX keeps it as preset 1001 with a drop and squash PowerPoint can play, and reopens it as Anvil | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort | | 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |@@ -239,6 +239,25 @@ impl EffectSpec { st.scale_x = 0.3 + 0.7 * e; st.opacity = e; }+ "anvil" => {+ // Keynote's Anvil: a free fall from above the slide (gravity: distance grows with t²),+ // then a squash on impact that rings out, with a puff of dust at the base. The curve is+ // the effect itself, so it ignores smoothing. Squashing keeps the base on the ground.+ const LAND: f64 = 0.45;+ if p < LAND {+ let t = p / LAND;+ st.offset_y = -(y + h) * (1.0 - t * t);+ } else {+ let u = ((p - LAND) / (1.0 - LAND)).clamp(0.0, 1.0);+ let squash = 0.14 * (u / 0.05).min(1.0) * (-4.5 * u).exp() * (2.5 * PI * u).cos();+ st.scale_y = 1.0 - squash;+ st.scale_x = 1.0 + 0.6 * squash;+ st.offset_y = h * squash / 2.0;+ if !exit && u < 1.0 {+ st.dust = u.max(1e-3);+ }+ }+ } "spinner" => { st.rotate = 360.0 * q; st.scale_x = 0.5 + 0.5 * e;@@ -60,11 +60,25 @@ pub struct AnimState { pub rotate: f64, pub clip: Option<[f64; 4]>, pub tint: Option<Rgba>,+ /// Progress (0..1) of a dust puff at the shape's base (Anvil's landing); 0 = none.+ #[serde(default)]+ pub dust: f64, } impl Default for AnimState { fn default() -> Self {- AnimState { visible: true, opacity: 1.0, offset_x: 0.0, offset_y: 0.0, scale_x: 1.0, scale_y: 1.0, rotate: 0.0, clip: None, tint: None }+ AnimState {+ visible: true,+ opacity: 1.0,+ offset_x: 0.0,+ offset_y: 0.0,+ scale_x: 1.0,+ scale_y: 1.0,+ rotate: 0.0,+ clip: None,+ tint: None,+ dust: 0.0,+ } } } @@ -78,7 +92,7 @@ impl AnimState { *self == AnimState::default() } /// Apply `d` on top of `self`: opacities and scales multiply, offsets and rotations add, clips- /// intersect, a tint replaces.+ /// intersect, a tint replaces, the larger dust puff wins. pub fn compose(&self, d: &AnimState) -> AnimState { let clip = match (self.clip, d.clip) { (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),@@ -95,6 +109,7 @@ impl AnimState { rotate: self.rotate + d.rotate, clip, tint: d.tint.or(self.tint),+ dust: self.dust.max(d.dust), } } /// Replace any non-finite number with its neutral value and clamp opacity and clip.@@ -106,6 +121,7 @@ impl AnimState { self.scale_x = f(self.scale_x, 1.0); self.scale_y = f(self.scale_y, 1.0); self.rotate = f(self.rotate, 0.0);+ self.dust = f(self.dust, 0.0).clamp(0.0, 1.0); self.clip = self.clip.map(|c| { let l = f(c[0], 0.0).clamp(0.0, 1.0); let t = f(c[1], 0.0).clamp(0.0, 1.0);@@ -191,6 +191,25 @@ fn every_animation_evaluates_finite() { } } +#[test]+fn anvil_drops_from_above_squashes_and_puffs_dust() {+ let tl = Timeline::new(&slide_with(vec![anim(1, AnimClass::Entrance, "anvil", AnimStart::OnClick)]), W, H);+ // Shape at y=100, h=100: starts fully above the slide, falling faster as it goes.+ let s0 = tl.state(ShapeId(1), None, 0, 1e-6);+ assert!(s0.visible && s0.offset_y <= -199.0, "{s0:?}");+ let (a, b, c) = (tl.state(ShapeId(1), None, 0, 0.1), tl.state(ShapeId(1), None, 0, 0.2), tl.state(ShapeId(1), None, 0, 0.3));+ assert!(b.offset_y - a.offset_y < c.offset_y - b.offset_y, "accelerates: {a:?} {b:?} {c:?}");+ assert_eq!(a.dust, 0.0);+ // Just after landing (45 %): flattened and widened with the base kept on the ground, dust rising.+ let hit = tl.state(ShapeId(1), None, 0, 0.5);+ assert!(hit.scale_y < 0.95 && hit.scale_x > 1.02, "{hit:?}");+ assert!((hit.offset_y - 100.0 * (1.0 - hit.scale_y) / 2.0).abs() < 1e-9, "{hit:?}");+ assert!(hit.dust > 0.0 && hit.dust < 1.0);+ // Done: at rest, no dust.+ let end = tl.state(ShapeId(1), None, 1, 0.0);+ assert!(end.is_identity(), "{end:?}");+}+ #[test] fn fly_starts_off_slide() { let mut a = anim(1, AnimClass::Entrance, "fly", AnimStart::OnClick);@@ -342,6 +342,10 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[ ("expand", "Expand", AnimClass::Entrance, 55, 500, &[]), ("rise", "Rise Up", AnimClass::Entrance, 37, 1000, &[]), ("spinner", "Spinner", AnimClass::Entrance, 49, 1000, &[]),+ // Keynote-style Anvil: drops from above the slide, lands with a squash and a puff of dust.+ // PowerPoint has no such preset; 1001 is a DeckCraft id outside PowerPoint's range, and the file+ // carries a drop-and-squash PowerPoint can play.+ ("anvil", "Anvil", AnimClass::Entrance, 1001, 1000, &[]), ("pulse", "Pulse", AnimClass::Emphasis, 26, 500, &[]), ("colorPulse", "Color Pulse", AnimClass::Emphasis, 27, 500, &[]), ("teeter", "Teeter", AnimClass::Emphasis, 32, 1000, &[]),@@ -192,6 +192,48 @@ fn anim_prop(w: &mut W, ids: &mut Ids, e: &Eff, dur: u32, attr: &str, from: &str w.close("p:anim"); } +/// Anvil as PowerPoint can play it: a gravity drop from above the slide sampled as key times+/// (distance grows with t²), landing at 45 % of the effect, then a squash flattened against the+/// ground (the box shifted down while it shrinks) that springs back. No dust: PowerPoint has none.+fn anvil(w: &mut W, ids: &mut Ids, e: &Eff, dur: u32) {+ let mut keys: Vec<(u32, String)> = (0..=6)+ .map(|k| {+ let t = k as f64 / 6.0;+ let tm = (t * 45_000.0).round() as u32;+ let rest = 1.0 - t * t;+ let v = if k == 6 { "#ppt_y".to_string() } else { format!("#ppt_y-(#ppt_y+#ppt_h/2)*{rest:.4}") };+ (tm, v)+ })+ .collect();+ keys.extend([(52_000, "#ppt_y+#ppt_h*0.06".to_string()), (60_000, "#ppt_y".to_string()), (100_000, "#ppt_y".to_string())]);+ w.open("p:anim", A::new().a("calcmode", "lin").a("valueType", "num"));+ cbhvr(w, ids, e, dur, None, &["ppt_y"], Some("base"));+ w.open0("p:tavLst");+ for (tm, v) in &keys {+ w.open("p:tav", A::new().a("tm", *tm));+ w.open0("p:val");+ w.val("p:strVal", v);+ w.close("p:val");+ w.close("p:tav");+ }+ w.close("p:tavLst");+ w.close("p:anim");+ // The squash: from the landing, flatten and widen, then spring back (autoRev).+ let at = (u64::from(dur) * 45 / 100) as u32;+ let half = ((u64::from(dur) * 7 / 100) as u32).max(1);+ w.open0("p:animScale");+ w.open0("p:cBhvr");+ w.open("p:cTn", A::new().a("id", ids.next()).a("dur", half).a("autoRev", "1").a("fill", "hold"));+ w.open0("p:stCondLst");+ w.empty("p:cond", A::new().a("delay", at));+ w.close("p:stCondLst");+ w.close("p:cTn");+ tgt(w, e.spid, e.paragraph);+ w.close("p:cBhvr");+ w.empty("p:by", A::new().a("x", 108_000).a("y", 88_000));+ w.close("p:animScale");+}+ fn filter_for(effect: &str, option: &str) -> &'static str { match effect { "wipe" | "wipeOut" => match option {@@ -318,6 +360,7 @@ fn behaviors(w: &mut W, x: &mut Exp, ids: &mut Ids, e: &Eff) { anim_prop(w, ids, e, dur, "ppt_h", "0", "#ppt_h"); anim_effect(w, ids, e, dur, "fade", false); }+ "anvil" => anvil(w, ids, e, dur), "float" | "rise" => { anim_prop(w, ids, e, dur, "ppt_y", if a.option == "d" { "#ppt_y-.1" } else { "#ppt_y+.1" }, "#ppt_y"); anim_effect(w, ids, e, dur, "fade", false);@@ -184,13 +184,25 @@ pub struct ShapeState { pub clip: Option<[f64; 4]>, /// Colour override for emphasis effects (fill). pub tint: Option<Rgba>,+ /// Progress (0..1) of a dust puff at the shape's base (Anvil's landing); 0 = none.+ pub dust: f64, /// Per-paragraph states of by-paragraph text builds (paragraph index, state). pub paras: Vec<(usize, ParaState)>, } impl Default for ShapeState { fn default() -> Self {- ShapeState { visible: true, opacity: 1.0, offset: Vec2::ZERO, scale: (1.0, 1.0), rotate: 0.0, clip: None, tint: None, paras: vec![] }+ ShapeState {+ visible: true,+ opacity: 1.0,+ offset: Vec2::ZERO,+ scale: (1.0, 1.0),+ rotate: 0.0,+ clip: None,+ tint: None,+ dust: 0.0,+ paras: vec![],+ } } } @@ -504,6 +516,55 @@ fn output_size(pres: &Presentation, opts: &RenderOpts) -> (u16, u16) { (w.clamp(1, MAX_SIDE) as u16, h.clamp(1, MAX_SIDE) as u16) } +/// Anvil's landing dust: soft grey clouds billowing out sideways from the base of the shape's box+/// (`m`: shape-local points to output pixels), `u` the puff's progress 0..1. Deterministic, so+/// every frame and export of the same moment match.+fn dust_puff(ctx: &mut RenderContext, m: Affine, w: f64, h: f64, u: f64) {+ if !(u.is_finite() && w.is_finite() && h.is_finite()) || w <= 0.0 || h <= 0.0 {+ return;+ }+ // The ground: the lowest edge of the (possibly rotated) box, in slide space.+ let corners = [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(0.0, h), Point::new(w, h)].map(|p| m * p);+ let (mut l, mut r, mut b) = (f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);+ for p in corners {+ l = l.min(p.x);+ r = r.max(p.x);+ b = b.max(p.y);+ }+ let bw = r - l;+ if !(bw.is_finite() && b.is_finite()) || bw <= 0.0 {+ return;+ }+ let cx = (l + r) / 2.0;+ let out = 1.0 - (1.0 - u).powi(3);+ let alpha = 0.6 * (u / 0.08).min(1.0) * (1.0 - u).powf(1.6);+ // Size the clouds from the box width in output pixels, within sensible bounds.+ let base = (bw * 0.09).clamp(4.0, 120.0);+ let tone = Rgba::rgb(0xB9, 0xB4, 0xAC);+ ctx.set_transform(Affine::IDENTITY);+ const N: usize = 12;+ for k in 0..N {+ // Fixed pseudo-random spread per puff.+ let a = ((k as f64 * 0.618_034) % 1.0 + 0.13 * (k % 3) as f64) % 1.0;+ let side = if k % 2 == 0 { -1.0 } else { 1.0 };+ let start = cx + side * bw / 2.0 * (0.25 + 0.75 * a);+ let px = start + side * bw * (0.12 + 0.38 * a) * out;+ let py = b - base * (0.2 + 0.9 * a) * out;+ let rad = base * (0.8 + 0.9 * out) * (0.75 + 0.5 * ((k * 7 % 5) as f64 / 4.0));+ if !(px.is_finite() && py.is_finite() && rad.is_finite()) || rad <= 0.0 {+ continue;+ }+ let c = Point::new(px, py);+ let stops = [+ peniko::ColorStop { offset: 0.0, color: color(tone, alpha).into() },+ peniko::ColorStop { offset: 0.55, color: color(tone, alpha * 0.55).into() },+ peniko::ColorStop { offset: 1.0, color: color(tone, 0.0).into() },+ ];+ ctx.set_paint(peniko::Gradient::new_radial(c, rad as f32).with_stops(stops.as_slice()));+ ctx.fill_rect(&Rect::new(px - rad, py - rad, px + rad, py + rad));+ }+}+ pub(crate) fn color(c: Rgba, alpha: f64) -> peniko::Color { let a = (c.a as f64 * alpha.clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8; peniko::Color::from_rgba8(c.r, c.g, c.b, a)@@ -990,6 +1051,9 @@ impl Renderer { if layered { ctx.pop_layer(); }+ if st.dust > 0.0 && st.dust < 1.0 {+ dust_puff(ctx, parent * x.affine(), x.w, x.h, st.dust);+ } } #[allow(clippy::too_many_arguments)]@@ -20,6 +20,7 @@ pub fn shape_state(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeStat rotate: a.rotate, clip: a.clip, tint: a.tint,+ dust: a.dust, paras: tl .paragraph_targets(id) .into_iter()@@ -1901,6 +1901,7 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { | "split" | "wipe" | "zoom"+ | "anvil" | "pulse" | "spin" | "growShrink"@@ -356,6 +356,7 @@ fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64, plays: &[TriggerPl rotate: a.rotate, clip: a.clip, tint: a.tint,+ dust: a.dust, paras: tl .paragraph_targets(id) .into_iter()@@ -1,6 +1,6 @@ # Transitions and animations: parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (community PR wave 2026-10-14: interactive-deck triggers, canvas preview, the Animation Pane timeline, repeat and bounce end round-trip; duplicated rows and headers folded; previous: major, transitions measured frame by frame against PowerPoint for Windows' video export) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-19 · **Change:** trivial (Anvil, a Keynote-style entrance beyond PowerPoint; previous: minor, community PR wave 2026-10-14: interactive-deck triggers, canvas preview, the Animation Pane timeline, repeat and bounce end round-trip; duplicated rows and headers folded; previous: major, transitions measured frame by frame against PowerPoint for Windows' video export) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Transitions and animations are what make a deck a presentation, and the part of PowerPoint people notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md)@@ -57,6 +57,14 @@ perspective-correct subdivided meshes. | Motion paths | 64 presets + custom | 40 + Custom Path (Lines, Arcs, Turns, Loops, Basic shapes such as stars, heart, moon, teardrop; Lines & Curves such as zigzag, waves, S curves, spirals, bounces; Special such as figure 8, bean, peanut, neutron) | Remaining ~24 presets (some Basic shapes, Lines & Curves variants) | | Media (Play, Pause, Stop) | 3 (+ Seek) | 3 | Seek, bookmarks as triggers | +Beyond PowerPoint: **Anvil** (entrance, in the ribbon's quick gallery), the effect Keynote users+know. The shape free-falls from above the slide, squashes on landing with its base kept on the+ground, and a puff of dust billows out sideways (`AnimState::dust`, drawn by the renderer). PowerPoint+has no such preset, so PPTX stores it under DeckCraft's preset id 1001 with behaviours PowerPoint+can play: the gravity drop as `ppt_y` key times and an `animScale` squash that springs back (no+dust). DeckCraft reads id 1001 back as Anvil (`anvil_drops_from_above_squashes_and_puffs_dust`,+`every_animation_effect_survives`). It is not counted in the PowerPoint scores above.+ By how often people use them, the effects present cover nearly all real decks (Appear, Fade, Fly In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85% of use; by count, ~34%.@@ -94,6 +102,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Date | Change | Summary | |---|---|---|+| 2026-10-19 | trivial | Anvil: a Keynote-style entrance beyond PowerPoint (drop, squash, dust), saved to PPTX as a drop PowerPoint can play; not counted in the scores | | 2026-10-15 | trivial | PPTX interactive sequences preserve later On Click effects and one click per paragraph; no change to playback or feature scores | | 2026-10-14 | trivial | Community PR wave folded: the Animation Pane timeline row says yes (it landed), preview split into canvas (yes) and from the selected effect (no); estimates drop the timeline and transition sounds (~15–27 h) | | 2026-10-14 | trivial | Repeat and bounce end round-trip through PPTX; rewind is still lost (#77) |