Merge pull request #1521

Slideshow click targets follow animated shapes
This commit is contained in:
Brandon Thomas
2026-10-31 00:56:35 -04:00
2 changed files with 81 additions and 24 deletions
+74 -17
View File
@@ -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,22 @@ pub(super) struct Under {
pub(super) pick: Option<Pick>,
}
/// Convert a slide-space point back into a shape's own coordinates, undoing its animation.
fn hit_point(point: (f64, f64), shapes: &[PlacedShape], states: &[AnimState], index: usize) -> Option<(f64, f64)> {
let shape = shapes.get(index)?;
inverse_transform_point(point, (shape.rect.center().x, shape.rect.center().y), *states.get(index)?)
}
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 +192,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<HitShape> = 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<bool> = shapes
let states: Vec<AnimState> = 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<HitShape> = 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<bool> = 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,
@@ -252,3 +267,45 @@ pub(super) fn gif_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc_uid: u64, d
let gifs = app.media.gifs(doc_uid, doc, show.state.slide, ctx.input(|i| i.time), &app.session.gif_paused);
gifs.iter().rev().find(|g| crate::media::gif_button_rect(on_screen(srect, k, &g.rect)).contains(p)).map(|g| g.id)
}
#[cfg(test)]
mod hit_tests {
use super::*;
fn shape(id: u32, parent: Option<usize>, 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 scale_and_rotation_move_the_hit_box() {
let shapes = [shape(1, None, deckcraft_geom::Rect::new(10.0, 10.0, 30.0, 30.0))];
let states = [AnimState { scale_x: 2.0, scale_y: 1.0, rotate: 90.0, ..Default::default() }];
// The shape center is (20, 20); local (25, 25) scales and rotates to (15, 30).
let transformed = hit_point((15.0, 30.0), &shapes, &states, 0);
assert!(transformed.is_some_and(|(x, y)| (24.9..=25.1).contains(&x) && (24.9..=25.1).contains(&y)));
}
#[test]
fn parent_animation_does_not_move_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 unchanged = hit_point((15.0, 20.0), &shapes, &states, 1);
let displaced = hit_point((55.0, 20.0), &shapes, &states, 1);
assert!(unchanged.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y)));
assert!(!displaced.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y)));
}
}
+7 -7
View File
@@ -1,8 +1,6 @@
# Where DeckCraft falls short of PowerPoint
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-28 · **Change:** trivial (AutoFormat lists run independently of Replace text as you type; 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-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-27 · **Last updated:** 2026-10-27 · **Change:** minor (docs refresh against merged PRs: #67 and #77 landed, #75 closed unmerged, #53 closed, Windows image paste is #1414; one header line; 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-30 · **Last updated:** 2026-10-30 · **Change:** minor (slide-show hit testing follows each shape’s own animation state; scale and rotation covered; previous: trivial, AutoFormat lists run independently of Replace text as you type; previous: 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)
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 +217,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
@@ -370,6 +369,7 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added |
| 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated |
| 2026-10-27 | minor | Refreshed against merged PRs: removed the Morph alignment entry (#67 merged 2026-10-14); #77 (animation rewind, PR #83) is fixed, leaving #74 in the round-trip gap; #75 was closed unmerged, so #60 stays open with no fix PR; #53 is closed and the image-paste gap is now Windows #1414; Reuse Slides (#1103) and Compare reject (#850) noted in the smaller rows; the duplicate header line is gone |
| 2026-10-25 | trivial | New entries: copying text and cell ranges (no media, no table), a control-server timing test that may fail under load; Proofing notes that automatic lists follow Replace text as you type and that Backspace puts the typed lead back |