Browse / github.com/storytold/deckcraft / patch
Slide show: play media actions and action sounds on click
Slide show: play media actions and action sounds on click `docs/gaps.md` ▸ "Interactive decks" says a click on a shape whose action plays media does nothing in the show. Action sounds didn't work either: the reader keeps `a:snd` and `endSnd` on a link, but the show never used them. This PR makes both work, in two commits. The second one can be reverted on its own. ### 1. Slide show: play media actions and action sounds on click - `ShowState::click` (anim) returns `Click::Media(shape)` when the clicked shape's action is `PlayMedia`. `follow` only sees the action, so it can't name the shape. The show stays on the slide. - The show (ui-egui) starts th
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- marcoarnulfo (imported from pull request #1209, open)
- Base
b47195feec0f0fe4220d8943ad1c8add5dad8072- Size
- +334 −14 in 12 files
- Review
- pending
- Shared
- 2026-10-28
- Id
ad6d41af33dd7b3d499e79d7
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/anim/src/interact.rs crates/anim/src/show.rs crates/anim/src/tests_interact.rs crates/model/src/text.rs crates/pptx/src/read/dml.rs crates/pptx/src/write/dml.rs crates/pptx/tests/roundtrip.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/show.rs docs/gaps.md docs/slideshow-parity.md
@@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** trivial (progress log: media actions and action sounds in the slide show; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere@@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---|+| 2026-10-23 | Slide show: a click on a shape whose action plays its clip (`ppaction://media`) plays it, also inside a group, and an action's sound plays along with the action; Stop Previous Sound stops the sounds and audio clips playing. A sound-only action (Action: None with Play Sound) survives opening a .pptx | | 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-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) | | 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 |@@ -241,6 +242,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---|+| 2026-10-23 | trivial | Progress log: media actions and action sounds in the slide show. Scores and milestone names unchanged | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged | | 2026-10-18 | trivial | Progress log: thumbnails of huge decks render only the slides in view (#969) |@@ -72,6 +72,8 @@ pub enum Click { Show(ShowAction), /// Open this URL outside the show. Open(String),+ /// Play the media of this shape (its action is `ppaction://media`).+ Media(ShapeId), /// End the show. Exit, }@@ -249,6 +251,8 @@ impl ShowState { } } match t.link {+ // The clip is the clicked shape's own, which `follow` does not know.+ Some(Action::PlayMedia) => Click::Media(t.shape), // The link is followed at once, whatever the animation still has to play. Some(a) => self.follow(pres, &a, last_viewed).into(), None if animated => Click::Animated,@@ -372,8 +372,9 @@ impl ShowState { self.custom = Some(name.clone()); return LinkJump::Show(self.goto(pres, i)); }- // Programs and media actions aren't followed.- Action::Program { .. } | Action::PlayMedia => None,+ // Programs aren't run. A media action plays its own shape's clip: `click` knows the+ // shape, this does not.+ Action::Program { .. } | Action::PlayMedia | Action::NoAction => None, }; match target { Some(i) => LinkJump::Show(self.goto(pres, i)),@@ -296,6 +296,19 @@ fn a_shape_with_a_link_and_an_animation_follows_the_link_without_waiting() { assert!(!r.visible(ShapeId(2))); } +#[test]+fn a_play_media_action_plays_the_clicked_shape_and_stays_on_the_slide() {+ // ppaction://media: the clip is the clicked shape's own. The click is on the shape, so the+ // background's "next" must not happen too.+ let s1 = slide(1000, vec![shape(1), shape(2)], vec![], true);+ let s2 = slide(1001, vec![], vec![], true);+ let pres = Presentation { slides: vec![s1, s2], ..Default::default() };+ let mut r = Run::new(pres, vec![vec![hit(1, Some(Action::PlayMedia)), hit(2, None)], vec![]]);+ r.hits[0][1].under_pointer = false;+ assert_eq!(r.click(), Click::Media(ShapeId(1)));+ assert_eq!(r.show.slide, 0);+}+ #[test] fn a_slide_comes_back_the_way_it_was_left() { let steps: Vec<Animation> = (1..=3)@@ -326,6 +326,8 @@ pub enum Action { path: String, }, PlayMedia,+ /// Action: None, kept for its sound or Stop Previous Sound (`ppaction://noaction`).+ NoAction, } fn custom_show_returns() -> bool {@@ -447,6 +447,7 @@ pub fn hyperlink(imp: &mut Imp, part: &Part, h: &El) -> Option<Hyperlink> { let rid = h.attr("r:id").unwrap_or(""); let rel = part.rels.get(rid); let action = h.attr("action").unwrap_or("");+ let stop_sound = h.bool("endSnd").unwrap_or(false); let a = if let Some(rest) = action.strip_prefix("ppaction://hlinkshowjump?jump=") { match rest { "nextslide" => Action::NextSlide,@@ -468,6 +469,14 @@ pub fn hyperlink(imp: &mut Imp, part: &Part, h: &El) -> Option<Hyperlink> { Action::Program { path: rel.map(|r| r.target.clone()).unwrap_or_default() } } else if action.starts_with("ppaction://media") { Action::PlayMedia+ } else if action.starts_with("ppaction://noaction") {+ // Action: None with Play Sound or Stop Previous Sound. Without either (or with a sound+ // that does not resolve, such as a missing part) it does nothing, and a link would only+ // swallow the click. A linked (external) sound resolves and is kept.+ if !stop_sound && h.child("snd").and_then(|s| media_ref(imp, part, s)).is_none() {+ return None;+ }+ Action::NoAction } else if action.is_empty() || action.starts_with("ppaction://hlinkfile") { let r = rel?; if r.target.is_empty() {@@ -487,7 +496,7 @@ pub fn hyperlink(imp: &mut Imp, part: &Part, h: &El) -> Option<Hyperlink> { action: a, tooltip: h.attr("tooltip").unwrap_or("").to_string(), highlight_click: h.bool("highlightClick").unwrap_or(false),- stop_sound: h.bool("endSnd").unwrap_or(false),+ stop_sound, sound: h.child("snd").and_then(|s| media_ref(imp, part, s)), zoom_return: None, })@@ -421,6 +421,7 @@ pub fn hyperlink(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, h: &Hyperlink) (o.rels.add("hyperlink", path, true), Some("ppaction://program".into())) } Action::PlayMedia => (String::new(), Some("ppaction://media".into())),+ Action::NoAction => (String::new(), Some("ppaction://noaction".into())), }; let attrs = A::new() .a("r:id", rid)@@ -1344,6 +1344,45 @@ fn transition_sound_options_round_trip() { assert!(s.execute("transition.sound", &json!({"builtin": "foghorn"})).is_err()); } +#[test]+fn a_sound_only_action_is_kept() {+ // Action Settings with Action: None and Play Sound. PowerPoint writes `ppaction://noaction`,+ // which "will continue to respect highlight attribute and snd element" ([MS-OI29500] 21.1.2.3.5).+ let mut p = Presentation::default();+ let chime = p.add_media("chime.wav", "audio/wav", b"RIFFdeckcraft-chime-WAVEfmt ".to_vec()).expect("media");+ let next =+ |sound| Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: false, sound, stop_sound: false, zoom_return: None };+ let mut buzz = shape(&mut p, "Buzz", 10.0, 10.0, 100.0, 50.0);+ buzz.click = Some(next(Some(chime)));+ let mut plain = shape(&mut p, "Plain", 10.0, 100.0, 100.0, 50.0);+ plain.click = Some(next(None));+ Arc::make_mut(&mut p.slides[0]).shapes = vec![buzz, plain];+ let bytes = deckcraft_pptx::export(&p).expect("export");+ let bytes = patch_slides(bytes, "ppaction://hlinkshowjump?jump=nextslide", "ppaction://noaction");+ let back = deckcraft_pptx::import(&bytes).expect("import");+ let click = |name: &str| back.slides[0].shapes.iter().find(|s| s.name == name).expect(name).click.clone();+ let buzz = click("Buzz").expect("the sound-only action is kept");+ assert_eq!(buzz.action, Action::NoAction);+ assert!(buzz.sound.is_some());+ assert_eq!(click("Plain"), None, "a link that does nothing at all is not kept");+ // And it is saved the way PowerPoint writes it.+ let again = deckcraft_pptx::export(&back).expect("export");+ let mut z = zip::ZipArchive::new(std::io::Cursor::new(&again)).expect("zip");+ let mut x = String::new();+ std::io::Read::read_to_string(&mut z.by_name("ppt/slides/slide1.xml").expect("slide"), &mut x).expect("read");+ assert!(x.contains(r#"action="ppaction://noaction""#), "{x}");++ // A sound that is not in the file leaves nothing to do: the link would only swallow the click.+ let mut ghost = shape(&mut p, "Ghost", 10.0, 200.0, 100.0, 50.0);+ ghost.click = Some(next(Some(chime)));+ Arc::make_mut(&mut p.slides[0]).shapes = vec![ghost];+ let bytes = deckcraft_pptx::export(&p).expect("export");+ let bytes = patch_slides(bytes, "ppaction://hlinkshowjump?jump=nextslide", "ppaction://noaction");+ let bytes = patch_slides(bytes, r#"<a:snd r:embed=""#, r#"<a:snd r:embed="rIdMissing" data-was=""#);+ let back = deckcraft_pptx::import(&bytes).expect("import");+ assert_eq!(back.slides[0].shapes.iter().find(|s| s.name == "Ghost").expect("Ghost").click, None);+}+ /// Replace `from` with `to` in every slide part of an exported deck. fn patch_slides(bytes: Vec<u8>, from: &str, to: &str) -> Vec<u8> { use std::io::{Read, Write};@@ -1384,7 +1384,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { }) .unwrap_or_default(); let (cur_kind, cur_arg) = match cur.as_ref().map(|h| &h.action) {- None => ("none", String::new()),+ None | Some(Action::NoAction) => ("none", String::new()), Some(Action::NextSlide) => ("next", String::new()), Some(Action::PreviousSlide) => ("previous", String::new()), Some(Action::FirstSlide) => ("first", String::new()),@@ -67,6 +67,17 @@ pub struct Show { media_clock: Option<(usize, f64)>, /// Media effects set off and not yet carried out: (shape, effect id). media_cues: Vec<(ShapeId, String)>,+ /// What clicked actions did to the show's sound, not yet carried out.+ sound_cues: Vec<SoundCue>,+}++/// A clicked action's effect on the sound voice the show shares with transition sounds.+#[derive(Clone, Copy, Debug, PartialEq)]+enum SoundCue {+ /// `a:snd`: play this sound in place of the one playing.+ Play(deckcraft_model::MediaId),+ /// Stop Previous Sound (`endSnd`).+ Stop, } #[derive(Clone, Debug, PartialEq)]@@ -208,6 +219,7 @@ impl Show { links: None, media_clock: None, media_cues: vec![],+ sound_cues: vec![], } } @@ -567,8 +579,13 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { if let Some(id) = gif_click { let _ = app.run("media.gifPlay", json!({"id": id})); } else if let Some((id, clip)) = media_click {+ // The clip's own action is not followed here, but its sound goes with the click.+ if let Some(l) = doc.slides.get(show.state.slide).and_then(|s| s.shapes.iter().find(|sh| sh.id == id)).and_then(|sh| sh.click.clone()) {+ show.cue_link_sound(&l);+ } app.media.toggle(doc_uid, &doc, id, &clip, crate::media::Owner::Show); } else if clicked && let Some(Pick::Text(l)) = under.as_ref().and_then(|u| u.pick.clone()) {+ show.cue_link_sound(&l); let last = show.last_viewed; match show.state.follow_link(&doc, &l, last) { LinkJump::Show(a) => actions.push(a),@@ -609,6 +626,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { Click::Show(a) => actions.push(a), Click::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), Click::Exit => keep = false,+ Click::Media(id) => show.media_cues.push((id, "play".into())), Click::Animated | Click::None => {} } }@@ -672,6 +690,9 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let step = show.state.step; show.cue_media(&doc, step, now - show.step_start); }+ // Before the new slide's media: a transition sound of the slide the click moved to replaces+ // the action's sound.+ run_sound_cues(app, &mut show, doc_uid, &doc); show_media(app, &mut show, doc_uid, &doc); run_media_cues(app, &mut show, doc_uid, &doc); // Draw.@@ -1060,9 +1081,32 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } } -/// The media-host voice for slide transition sounds (no shape has this id).+/// The media-host voice for slide transition sounds and the sounds of clicked actions (no shape+/// has this id). One voice: a new sound replaces the one playing. pub(crate) const TRANSITION_SOUND: ShapeId = ShapeId(u32::MAX); +/// Carry out the sounds of clicked actions: Stop Previous Sound, then the action's own sound.+fn run_sound_cues(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentation) {+ for cue in std::mem::take(&mut show.sound_cues) {+ match cue {+ // `endSnd` "should stop all sounds that are playing when it is clicked" (ECMA-376+ // Part 1, hlinkClick): the show's sound and its audio clips. Videos keep playing.+ SoundCue::Stop => {+ for id in app.media.ids() {+ let audio = crate::media::find_clip(doc, id).is_some_and(|c| !c.video);+ if id == TRANSITION_SOUND || (audio && app.media.owner(id) == Some(crate::media::Owner::Show)) {+ app.media.stop(id);+ }+ }+ }+ SoundCue::Play(media) => {+ let clip = deckcraft_model::MediaClip { media, volume: 1.0, ..Default::default() };+ app.media.play(doc_uid, doc, TRANSITION_SOUND, &clip, Some(0.0), crate::media::Owner::Show);+ }+ }+ }+}+ /// Start the media of a newly shown slide: media from the previous slide stops unless it plays /// across slides; media set to start automatically plays. Everything stops at the end. fn show_media(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentation) {@@ -1284,9 +1328,24 @@ impl Show { fn click_slide(&mut self, doc: &Presentation, under: &Under, now: f64) -> Click { self.timeline(doc); let last = self.last_viewed;- match &self.timeline {- Some((_, tl)) => self.state.click(doc, tl, &under.shapes, &|id| under.visible(id), now, last),- None => Click::None,+ let Some((_, tl)) = &self.timeline else { return Click::None };+ // The sound goes with the clicked shape's action, whatever the action does.+ let target = self.state.target(doc, tl, &under.shapes, &|id| under.visible(id));+ let link = target.and_then(|t| self.links.as_ref()?.2.iter().find(|s| s.id == t.shape)?.link.clone());+ let click = self.state.click(doc, tl, &under.shapes, &|id| under.visible(id), now, last);+ if let Some(l) = link {+ self.cue_link_sound(&l);+ }+ click+ }++ /// Queue what a followed link does to the show's sound.+ fn cue_link_sound(&mut self, link: &Hyperlink) {+ if link.stop_sound {+ self.sound_cues.push(SoundCue::Stop);+ }+ if let Some(media) = link.sound {+ self.sound_cues.push(SoundCue::Play(media)); } } }@@ -2078,6 +2137,188 @@ mod tests { assert!(show.media_cues.is_empty()); } + /// 16-bit mono silent WAV, `secs` long at 8 kHz.+ fn wav(secs: f64) -> Vec<u8> {+ let len = (8_000.0 * secs) as u32 * 2;+ let mut b = Vec::new();+ b.extend_from_slice(b"RIFF");+ b.extend_from_slice(&(36 + len).to_le_bytes());+ b.extend_from_slice(b"WAVEfmt ");+ // PCM, mono, 8 kHz, 16 000 bytes a second, 2-byte frames, 16 bits.+ for v in [16u32, 1 | (1 << 16), 8_000, 16_000, 2 | (16 << 16)] {+ b.extend_from_slice(&v.to_le_bytes());+ }+ b.extend_from_slice(b"data");+ b.extend_from_slice(&len.to_le_bytes());+ b.resize(b.len() + len as usize, 0);+ b+ }++ fn action(action: deckcraft_model::text::Action, sound: Option<deckcraft_model::MediaId>, stop_sound: bool) -> Hyperlink {+ Hyperlink { action, tooltip: String::new(), highlight_click: false, sound, stop_sound, zoom_return: None }+ }++ /// A slide show of `doc` in a 1000×700 window, the slides filling it.+ fn show_harness(doc: Presentation) -> egui_kittest::Harness<'static, SlideApp> {+ let mut session = deckcraft_engine::Session::with_new();+ session.active_mut().unwrap().doc = std::sync::Arc::new(doc);+ let app = SlideApp::new(session, crate::Services::default());+ let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(+ |ui, app: &mut SlideApp| {+ app.logic(ui.ctx());+ app.ui(ui);+ },+ app,+ );+ harness.run_steps(3);+ harness.state_mut().start_show(0, false);+ harness.run_steps(2);+ harness+ }++ fn click_middle(harness: &mut egui_kittest::Harness<'static, SlideApp>) {+ let p = pos2(500.0, 350.0);+ harness.hover_at(p);+ harness.run_steps(1);+ for pressed in [true, false] {+ harness.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });+ }+ harness.run_steps(2);+ }++ fn slide_shape(id: u32, doc: &Presentation, click: Hyperlink) -> deckcraft_model::Shape {+ let (w, h) = (doc.slide_size.width, doc.slide_size.height);+ deckcraft_model::Shape { id: ShapeId(id), xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, w, h)), click: Some(click), ..Default::default() }+ }++ /// The show's sound voice is playing (headless: what would play, no speaker needed).+ fn sound_playing(harness: &egui_kittest::Harness<'static, SlideApp>) -> bool {+ harness.state().media.is_playing(TRANSITION_SOUND)+ }++ #[test]+ fn a_clicked_action_plays_its_sound_and_stop_previous_sound_ends_it() {+ use deckcraft_model::text::Action;+ let mut doc = Presentation::default();+ doc.slides.push(std::sync::Arc::new(deckcraft_model::Slide { id: deckcraft_model::SlideId(7001), ..Default::default() }));+ let chime = doc.add_media("chime.wav", "audio/wav", wav(30.0)).unwrap();+ // Slide 1: Next Slide with a sound, the sound goes on over slide 2 (PowerPoint plays the+ // sound and does the action). Slide 2: Previous Slide with Stop Previous Sound.+ let next = slide_shape(9001, &doc, action(Action::NextSlide, Some(chime), false));+ let back = slide_shape(9002, &doc, action(Action::PreviousSlide, None, true));+ std::sync::Arc::make_mut(&mut doc.slides[0]).shapes.push(next);+ std::sync::Arc::make_mut(&mut doc.slides[1]).shapes.push(back);+ let mut harness = show_harness(doc);+ assert!(!sound_playing(&harness));+ click_middle(&mut harness);+ assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(1), "the action still runs");+ assert!(sound_playing(&harness), "the action's sound plays");+ click_middle(&mut harness);+ assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0));+ assert!(!sound_playing(&harness), "Stop Previous Sound ends it");+ }++ #[test]+ fn stop_previous_sound_also_stops_audio_clips() {+ use deckcraft_model::text::Action;+ // Background music that plays across slides, and a Next button with Stop Previous Sound.+ let mut doc = Presentation::default();+ doc.slides.push(std::sync::Arc::new(deckcraft_model::Slide { id: deckcraft_model::SlideId(7001), ..Default::default() }));+ let tune = doc.add_media("tune.wav", "audio/wav", wav(30.0)).unwrap();+ let clip = deckcraft_model::MediaClip { media: tune, volume: 1.0, autoplay: true, play_across_slides: true, ..Default::default() };+ let music = deckcraft_model::Shape {+ id: ShapeId(9020),+ xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 20.0, 20.0)),+ kind: deckcraft_model::ShapeKind::Media(clip),+ ..Default::default()+ };+ let next = slide_shape(9021, &doc, action(Action::NextSlide, None, true));+ std::sync::Arc::make_mut(&mut doc.slides[0]).shapes.extend([music, next]);+ let mut harness = show_harness(doc);+ assert!(harness.state().media.is_playing(ShapeId(9020)), "the music starts with the slide");+ click_middle(&mut harness);+ assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(1));+ assert!(!harness.state().media.is_playing(ShapeId(9020)), "Stop Previous Sound stops it");+ }++ #[test]+ fn the_next_slides_transition_sound_replaces_the_actions_sound() {+ use deckcraft_model::text::Action;+ let mut doc = Presentation::default();+ let chime = doc.add_media("chime.wav", "audio/wav", wav(30.0)).unwrap();+ let whoosh = doc.add_media("whoosh.wav", "audio/wav", wav(2.0)).unwrap();+ let transition = deckcraft_model::anim::Transition { sound: Some(whoosh), ..Default::default() };+ doc.slides.push(std::sync::Arc::new(deckcraft_model::Slide {+ id: deckcraft_model::SlideId(7001),+ transition: Some(transition),+ ..Default::default()+ }));+ let next = slide_shape(9001, &doc, action(Action::NextSlide, Some(chime), false));+ std::sync::Arc::make_mut(&mut doc.slides[0]).shapes.push(next);+ let mut harness = show_harness(doc);+ click_middle(&mut harness);+ assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(1));+ // One voice: the later sound, the transition's 2 s one, is what plays.+ let length = harness.state().media.status(TRANSITION_SOUND).map(|s| s.duration);+ assert!(length.is_some_and(|d| (d - 2.0).abs() < 0.1), "{length:?}");+ }++ #[test]+ fn a_sound_only_action_plays_its_sound_and_stays_on_the_slide() {+ use deckcraft_model::text::Action;+ let mut doc = Presentation::default();+ doc.slides.push(std::sync::Arc::new(deckcraft_model::Slide { id: deckcraft_model::SlideId(7001), ..Default::default() }));+ let buzz = doc.add_media("buzz.wav", "audio/wav", wav(30.0)).unwrap();+ let button = slide_shape(9001, &doc, action(Action::NoAction, Some(buzz), false));+ std::sync::Arc::make_mut(&mut doc.slides[0]).shapes.push(button);+ let mut harness = show_harness(doc);+ click_middle(&mut harness);+ assert!(sound_playing(&harness));+ assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0), "a click on the button is not a click on the slide");+ }++ #[test]+ fn a_play_media_action_plays_a_clip_inside_a_group() {+ use deckcraft_model::text::Action;+ // A click on a clip that is on the slide itself toggles it already (`media_hit`); one+ // inside a group is only reached through its action.+ let mut doc = Presentation::default();+ let media = doc.add_media("clip.wav", "audio/wav", wav(30.0)).unwrap();+ let clip = deckcraft_model::MediaClip { media, volume: 1.0, ..Default::default() };+ let mut audio = slide_shape(9011, &doc, action(Action::PlayMedia, None, false));+ audio.kind = deckcraft_model::ShapeKind::Media(clip);+ let (w, h) = (doc.slide_size.width, doc.slide_size.height);+ let group = deckcraft_model::Shape {+ id: ShapeId(9010),+ xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, w, h)),+ kind: deckcraft_model::ShapeKind::Group { children: vec![audio], child: deckcraft_geom::Xfrm::new(0.0, 0.0, w, h) },+ ..Default::default()+ };+ std::sync::Arc::make_mut(&mut doc.slides[0]).shapes.push(group);+ let mut harness = show_harness(doc);+ assert!(!harness.state().media.is_playing(ShapeId(9011)));+ click_middle(&mut harness);+ assert!(harness.state().media.is_playing(ShapeId(9011)), "the clip plays");+ assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0), "and the show stays on the slide");+ }++ #[test]+ fn a_clip_on_the_slide_plays_its_action_sound_when_clicked() {+ use deckcraft_model::text::Action;+ // `media_hit` takes this click, not the shape's action: the sound has to go with it.+ let mut doc = Presentation::default();+ let media = doc.add_media("clip.wav", "audio/wav", wav(30.0)).unwrap();+ let chime = doc.add_media("chime.wav", "audio/wav", wav(30.0)).unwrap();+ let clip = deckcraft_model::MediaClip { media, volume: 1.0, ..Default::default() };+ let mut audio = slide_shape(9031, &doc, action(Action::PlayMedia, Some(chime), false));+ audio.kind = deckcraft_model::ShapeKind::Media(clip);+ std::sync::Arc::make_mut(&mut doc.slides[0]).shapes.push(audio);+ let mut harness = show_harness(doc);+ click_middle(&mut harness);+ assert!(harness.state().media.is_playing(ShapeId(9031)), "the clip plays");+ assert!(sound_playing(&harness), "and so does its action's sound");+ }+ #[test] fn the_laser_and_the_pen_are_not_on_together() { let doc = deckcraft_model::Presentation::default();@@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** minor (Interactive decks: media actions and action sounds play in the show; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in@@ -211,8 +211,13 @@ of the effect gallery. Detail in [animation-parity.md](animation-parity.md). 8 - **Evidence:** the slide show plays click-triggered animations, keeps each slide's state across visits and ignores shapes an animation has hidden (`crates/anim/src/interact.rs`). Still open, found by running three classroom game decks (Jeopardy-style boards, hide-and-reveal, score- counters): a click on a shape whose action is "play sound" (`ppaction://media`, 126 and 486- shapes in two decks) does nothing; triggers that start on `onStopAudio` or `begin` conditions are+ counters): two decks have 126 and 486 shapes whose action plays their clip+ (`ppaction://media`) and were seen doing nothing. A clip directly on the slide already played+ when clicked, and those decks have not been run again, so why is not known. What the show does+ now (`crates/ui-egui/src/show.rs`): a media action plays its clip also inside a group, an+ action's sound (`a:snd`) plays with the action, and Stop Previous Sound stops the sounds and+ audio clips that are playing. A sound-only action (`ppaction://noaction`) is kept from PPTX.+ Still open: hover sounds do not play; triggers that start on `onStopAudio` or `begin` conditions are not read as triggers; click boxes are the shape's bounding box (a hexagon's corners count) and do not follow a shape that a motion path has moved. Trigger sequences now keep their separate click steps on PPTX save, including paragraph builds (`crates/pptx/tests/trigger_steps.rs`).@@ -356,6 +361,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---|+| 2026-10-23 | minor | Interactive decks: media actions play clips in groups, action sounds play with their action and Stop Previous Sound stops sounds and audio clips; sound-only actions are read from PPTX. The two game decks were not run again, so the entry stays open with hover sounds, audio-started triggers and click boxes | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-15 | trivial | Native saves retain chart package parts and relationships, including charts in groups; edited chart data still does not export a stale source workbook. Chart editing gaps and scores remain unchanged | | 2026-10-15 | trivial | PPTX trigger sequences keep later On Click steps instead of converting them to After Previous; mixed starts, independent buttons and paragraph builds have synthetic round-trip regressions |@@ -1,6 +1,6 @@ # Slide show and presenter view: parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: interactive decks, laser pointer, See All Slides and zoom, go-to-slide number, custom show playback, presenter timer, clock, notes size and keyboard, Show Media Controls, swipe; breadth 68%; duplicated rows and headers folded; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** minor (media actions and action sounds play in the show; previous: major, community PR wave 2026-10-14: interactive decks, laser pointer, See All Slides and zoom, go-to-slide number, custom show playback, presenter timer, clock, notes size and keyboard, Show Media Controls, swipe; breadth 68%; duplicated rows and headers folded; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view, rehearsal and recording. Transition and animation playback are in@@ -22,7 +22,8 @@ ready** (estimated), **10–16 h** to parity. | Step clock: a click after a long pause plays its step (#11) | yes | yes | | | Hidden slides skipped | yes | yes | | | Hyperlinks and action settings followed (slides, web, mail, End Show, Last Viewed) | yes | yes | Only `http(s)` and `mailto` open outside the show, by design (`safe_url`) |-| A shape with a triggered animation plays it when clicked; shapes an animation has hidden cannot be clicked; a shape without an action on top of one with an action blocks it; the pointer is a hand over what can be clicked | yes | yes | Click boxes are bounding boxes; "play sound" actions (`ppaction://media`) do nothing yet |+| A shape with a triggered animation plays it when clicked; shapes an animation has hidden cannot be clicked; a shape without an action on top of one with an action blocks it; the pointer is a hand over what can be clicked | yes | yes | Click boxes are bounding boxes |+| A click on a shape whose action plays its clip (`ppaction://media`) plays it, also inside a group; an action's sound plays with the action, and Stop Previous Sound stops the sounds and audio clips that are playing | yes | yes | Action and transition sounds share one voice, so a new one replaces the last. A sound-only action (Action: None, `ppaction://noaction`) is kept from PPTX. Hover sounds do not play | | A slide the show returns to (link, arrow keys) looks as it was left; a triggered animation goes on where it stopped | yes | yes | A main-sequence step still playing when the show leaves is finished | | "Advance on click" off: a click never leaves the slide (animation steps still play, keys still move on) | yes | yes | | | Hyperlinks and action settings followed (slides, web, mail, End Show, Last Viewed, Custom Show) | yes | yes | Only `http(s)` and `mailto` open outside the show, by design (`safe_url`). A Custom Show action plays that show |@@ -79,6 +80,7 @@ Reading View in a window with navigation: present (checklist row done). | Date | Change | Summary | |---|---|---|+| 2026-10-23 | minor | Media actions (`ppaction://media`) play the clicked clip, also inside a group; action sounds (also sound-only actions) play with their action and Stop Previous Sound stops sounds and audio clips. Checklist breadth unchanged | | 2026-10-14 | trivial | Community PR wave folded: one media playback row, the presenter pen/laser row matches the laser that exists, estimates keep only the open work (~9–16 h); checklist breadth 68% | | 2026-10-13 | minor | Click handling for interactive decks: triggers, per-slide state kept across visits, visible-shape hit testing with hand cursor, advance on click | | 2026-10-13 | minor | Video seek handoff no longer discards freshly decoded frames or end-of-stream (upstream #416); deterministic queue and four-codec seek regression tests |