From 14eef3aceb47119a0fd7a5d482b2bdcafd53be03 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:09:41 -0500 Subject: [PATCH] dc-show-click-targets: follow animated show click targets --- crates/ui-egui/src/show/media.rs | 112 ++++++++++++++++++++++++++----- docs/gaps.md | 14 ++-- 2 files changed, 103 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/show/media.rs b/crates/ui-egui/src/show/media.rs index ee8b1dec..ff69c3f4 100644 --- a/crates/ui-egui/src/show/media.rs +++ b/crates/ui-egui/src/show/media.rs @@ -1,5 +1,6 @@ use super::render::fit; -use super::{Click, HitShape, Hyperlink, Presentation, Rect, ShapeId, Show, SlideApp, Ui, pos2, vec2}; +use super::{Click, HitShape, Hyperlink, PlacedShape, Presentation, Rect, ShapeId, Show, SlideApp, Ui, pos2, vec2}; +use deckcraft_anim::AnimState; /// The media-host voice for slide transition sounds (no shape has this id). pub(crate) const TRANSITION_SOUND: ShapeId = ShapeId(u32::MAX); @@ -152,6 +153,55 @@ pub(super) struct Under { pub(super) pick: Option, } +/// Convert a slide-space point back into a shape's own coordinates, undoing its animation and +/// those of its enclosing groups. +fn hit_point(mut point: (f64, f64), shapes: &[PlacedShape], states: &[AnimState], index: usize) -> Option<(f64, f64)> { + let mut chain = Vec::new(); + let mut at = Some(index); + for _ in 0..=shapes.len() { + let Some(i) = at else { break }; + let shape = shapes.get(i)?; + chain.push(i); + at = shape.parent; + } + if at.is_some() { + return None; + } + chain.reverse(); + let mut centers = Vec::with_capacity(chain.len()); + for &i in &chain { + let r = shapes.get(i)?.rect; + let center = transform_point((r.center().x, r.center().y), centers.last().copied()); + centers.push((center, *states.get(i)?)); + } + for &(center, state) in centers.iter().rev() { + point = inverse_transform_point(point, center, state)?; + } + Some(point) +} + +fn transform_point(mut p: (f64, f64), prior: Option<((f64, f64), AnimState)>) -> (f64, f64) { + if let Some((center, state)) = prior { + let (x, y) = (p.0 - center.0, p.1 - center.1); + let angle = state.rotate.to_radians(); + p = ( + center.0 + (x * angle.cos() - y * angle.sin()) * state.scale_x + state.offset_x, + center.1 + (x * angle.sin() + y * angle.cos()) * state.scale_y + state.offset_y, + ); + } + p +} + +fn inverse_transform_point(p: (f64, f64), center: (f64, f64), state: AnimState) -> Option<(f64, f64)> { + if state.scale_x.abs() <= f64::EPSILON || state.scale_y.abs() <= f64::EPSILON { + return None; + } + let (x, y) = (p.0 - center.0 - state.offset_x, p.1 - center.1 - state.offset_y); + let angle = -state.rotate.to_radians(); + let (x, y) = (x * angle.cos() - y * angle.sin(), x * angle.sin() + y * angle.cos()); + Some((center.0 + x / state.scale_x, center.1 + y / state.scale_y)) +} + impl Under { fn visible(&self, id: ShapeId) -> bool { self.shapes.iter().zip(&self.visible).find(|(h, _)| h.id == id).is_none_or(|(_, v)| *v) @@ -175,31 +225,29 @@ pub(super) fn shapes_under_pointer(ctx: &egui::Context, ui: &Ui, show: &mut Show } show.timeline(doc); let (Some((_, tl)), Some((_, links, shapes))) = (&show.timeline, &show.links) else { return None }; - let inside = |x0: f64, y0: f64, x1: f64, y1: f64| x0 <= x && x <= x1 && y0 <= y && y <= y1; - let hits: Vec = shapes - .iter() - .map(|s| HitShape { - id: s.id, - parent: s.parent, - is_group: s.is_group, - link: s.link.as_ref().map(|l| l.action.clone()), - under_pointer: inside(s.rect.x0, s.rect.y0, s.rect.x1, s.rect.y1), - }) - .collect(); let (step, plays, stamps) = (show.state.step, show.state.trigger_plays(), show.state.trigger_stamps()); let t = if show.state.playing { now - show.step_start } else { 0.0 }; - let visible: Vec = shapes + let states: Vec = shapes.iter().map(|s| tl.state_ordered(s.id, None, step, t, &plays, &stamps)).collect(); + let point_for = |i: usize| hit_point((x, y), shapes, &states, i); + let hits: Vec = shapes .iter() - .map(|s| { - let a = tl.state_ordered(s.id, None, step, t, &plays, &stamps); - a.visible && a.opacity > 0.0 + .enumerate() + .map(|(i, s)| { + let under_pointer = point_for(i).is_some_and(|(x, y)| s.rect.x0 <= x && x <= s.rect.x1 && s.rect.y0 <= y && y <= s.rect.y1); + HitShape { id: s.id, parent: s.parent, is_group: s.is_group, link: s.link.as_ref().map(|l| l.action.clone()), under_pointer } }) .collect(); + let visible: Vec = states.iter().map(|a| a.visible && a.opacity > 0.0).collect(); let mut under = Under { shapes: hits, visible, pick: None }; let shown = |id: ShapeId| under.visible(id); // A hyperlink in text applies when its shape is the one on top. let top = show.state.top(doc, tl, &under.shapes, &shown); - let text = links.iter().rev().filter(|l| l.text && inside(l.rect.x0, l.rect.y0, l.rect.x1, l.rect.y1)).find(|l| { + let text = links.iter().rev().filter(|l| l.text).find(|l| { + let Some(i) = shapes.iter().position(|s| s.id == l.shape) else { return false }; + let Some((x, y)) = point_for(i) else { return false }; + if !(l.rect.x0 <= x && x <= l.rect.x1 && l.rect.y0 <= y && y <= l.rect.y1) { + return false; + } let mut at = top; (0..=under.shapes.len()).any(|_| match at.and_then(|k| under.shapes.get(k)) { Some(h) if h.id == l.shape => true, @@ -217,6 +265,36 @@ pub(super) fn shapes_under_pointer(ctx: &egui::Context, ui: &Ui, show: &mut Show Some(under) } +#[cfg(test)] +mod hit_tests { + use super::*; + + fn shape(id: u32, parent: Option, rect: deckcraft_geom::Rect) -> PlacedShape { + PlacedShape { id: ShapeId(id), parent, is_group: parent.is_none(), rect, link: None } + } + + #[test] + fn motion_path_moves_the_hit_box() { + let shapes = [shape(1, None, deckcraft_geom::Rect::new(10.0, 10.0, 30.0, 30.0))]; + let states = [AnimState { offset_x: 50.0, scale_x: 2.0, ..Default::default() }]; + + let moved = hit_point((65.0, 20.0), &shapes, &states, 0); + let abandoned = hit_point((15.0, 20.0), &shapes, &states, 0); + assert!(moved.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y))); + assert!(!abandoned.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y))); + } + + #[test] + fn parent_animation_moves_child_hit_box() { + let shapes = + [shape(1, None, deckcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0)), shape(2, Some(0), deckcraft_geom::Rect::new(10.0, 10.0, 30.0, 30.0))]; + let states = [AnimState { offset_x: 40.0, ..Default::default() }, AnimState::default()]; + + let moved = hit_point((55.0, 20.0), &shapes, &states, 1); + assert!(moved.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y))); + } +} + impl Show { /// Advance the clock of the running triggered animations. pub(super) fn tick_triggers(&mut self, doc: &Presentation, now: f64) { diff --git a/docs/gaps.md b/docs/gaps.md index c4a875f1..9512b671 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,7 +1,7 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** minor (Animation Pane ▸ Play From; 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) -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; 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) +> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** minor (slide-show click targets follow animated shapes and text links; previous: minor, Animation Pane ▸ Play From; 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) +> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** minor (slide-show click targets follow animated shapes and text links; previous: trivial, empty chart number-format sections hide values; 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 @@ -219,11 +219,12 @@ and starts at that effect. The pane has an advanced timeline with draggable bars 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 - 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 + not read as triggers; click boxes are the shape's bounding box (a hexagon's corners count). + Slide-show click targets now follow animated translations and scales, including text links. + Trigger sequences now keep their separate click steps on PPTX save, including paragraph builds (`crates/pptx/tests/it/trigger_steps.rs`). -- **Impact:** games built around sound effects on buttons, audio-triggered effects or moving - clickable shapes do not play as authored. +- **Impact:** games built around sound effects on buttons or audio-triggered effects do not play + as authored. - **Estimate:** 4–8 h. ### Notes and Handout masters @@ -440,6 +441,7 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-13 | trivial | Chart workbook round-trip (#335) noted under the charts gap; editing chart data is still open | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features | +| 2026-10-28 | minor | Slide-show click targets follow animated movement and scale for shapes and text links; sound actions and audio triggers remain open | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | | 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |