Slide show: click-triggered animations, per-slide state and hit testing

Make the slide show behave like PowerPoint for interactive decks.

- Hit testing resolves the topmost visible shape under the pointer.
  Non-interactive shapes block shapes beneath them; the hand cursor
  shows only over interactive shapes.
- Click-triggered animations (interactive sequences) play on shape
  click, repeat after their last step, and apply in click order so
  cycling "score ring" shapes work.
- Each slide keeps its own progress: returning to a slide through a
  link or Back restores hidden shapes and animation state.
- A shape with a link and an animation follows the link immediately;
  its animation clock only runs while the slide is shown and resumes
  with the remaining time on return.
- With advance-on-click off, background clicks still play animation
  steps but never change slide.
- A click on a playing step completes it at once.

Docs (gaps, animation and slide show parity, roadmap) are updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-10-14 17:22:23 +09:00
committed by nagasakimark
parent 457406a7a5
commit 887ea1e706
16 changed files with 1210 additions and 50 deletions
Generated
+1
View File
@@ -939,6 +939,7 @@ dependencies = [
name = "deckcraft-engine" name = "deckcraft-engine"
version = "0.6.1" version = "0.6.1"
dependencies = [ dependencies = [
"deckcraft-anim",
"deckcraft-color", "deckcraft-color",
"deckcraft-fonts", "deckcraft-fonts",
"deckcraft-format", "deckcraft-format",
+2
View File
@@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
| 2026-10-13 | Slide show for interactive decks: click-triggered animations play (one step per click, repeating, applied in click order), a slide the show returns to keeps its state and its triggered animations go on where they stopped, shapes an animation has hidden cannot be clicked, a shape without an action blocks the one beneath it, hand cursor over clickable shapes, "advance on click" off respected |
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
@@ -151,6 +152,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-13 | trivial | Progress log: wild text cases |
| 2026-10-13 | trivial | Progress log: slide show for interactive decks |
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
+266
View File
@@ -0,0 +1,266 @@
//! Clicks in a slide show: which shape a click lands on, what it starts (a triggered animation, a
//! link, the next step) and what the show remembers about each slide it has visited.
//!
//! The rules follow PowerPoint, as observed on its slide show:
//! - the topmost visible shape under the pointer decides; a shape without an action on top of one
//! with an action blocks it (the click then counts as a click on the background);
//! - a shape with a triggered animation plays the next step of it per click, and starts over once all
//! its steps have played; clicking it while a step plays completes the step at once;
//! - a shape with a link and a triggered animation follows the link at once. The animation's clock
//! runs only while its slide is shown, so it goes on where it stopped when the show returns;
//! - with "advance on click" off a click never leaves the slide, but it still plays animation steps;
//! - a slide the show returns to (link or arrow keys) looks as it was left.
use deckcraft_model::text::Action;
use deckcraft_model::{Presentation, ShapeId};
use crate::{AnimState, LinkJump, ShowAction, ShowState, StepStamp, Timeline, TriggerPlay};
/// What was played on one slide.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct SlideRun {
/// The show has been on this slide.
pub visited: bool,
/// Main-sequence steps played when the show left the slide.
pub step: usize,
/// Triggered sequences started by clicks.
pub triggers: Vec<TriggerRun>,
/// Steps of triggered sequences started so far (the stamps of [`StepStamp`]).
pub clock: u64,
}
/// Progress of one triggered sequence.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct TriggerRun {
pub shape: ShapeId,
/// Clicks whose step has played to its end.
pub done: usize,
/// Seconds the current step has played; `None` when no step is playing.
pub elapsed: Option<f64>,
/// When each step of this run was started, by step.
pub stamps: Vec<u64>,
}
/// One shape of the slide for hit testing, in drawing order (back to front; a group before its
/// children), with whether the pointer is inside its box.
#[derive(Clone, Debug, PartialEq)]
pub struct HitShape {
pub id: ShapeId,
/// Index of the enclosing group in the same list.
pub parent: Option<usize>,
pub is_group: bool,
/// The shape's own action.
pub link: Option<Action>,
pub under_pointer: bool,
}
/// What the pointer is over: a shape that does something when clicked.
#[derive(Clone, Debug, PartialEq)]
pub struct Target {
pub shape: ShapeId,
pub link: Option<Action>,
}
/// What a click did.
#[derive(Clone, Debug, PartialEq)]
pub enum Click {
/// Nothing happened.
None,
/// A triggered animation started or was completed: redraw.
Animated,
/// Navigation inside the show (already applied to the state).
Show(ShowAction),
/// Open this URL outside the show.
Open(String),
/// End the show.
Exit,
}
impl From<LinkJump> for Click {
fn from(j: LinkJump) -> Click {
match j {
LinkJump::Show(a) => Click::Show(a),
LinkJump::Open(u) => Click::Open(u),
LinkJump::Exit => Click::Exit,
LinkJump::None => Click::None,
}
}
}
fn finite_or_zero(v: f64) -> f64 {
if v.is_finite() { v } else { 0.0 }
}
impl ShowState {
fn animated(&self, pres: &Presentation) -> bool {
!pres.show.without_animation
}
/// Does clicking `shape` start a triggered sequence? A sequence can be started again once it has
/// played to its end.
fn trigger_live(&self, pres: &Presentation, tl: &Timeline, shape: ShapeId) -> bool {
self.animated(pres) && tl.trigger_steps(shape) > 0
}
/// The playheads of the current slide's triggered sequences at the last tick, for
/// [`Timeline::state_with`].
pub fn trigger_plays(&self) -> Vec<TriggerPlay> {
match self.runs.get(&self.slide) {
Some(r) => r.triggers.iter().map(|t| TriggerPlay { trigger: t.shape, step: t.done, t: t.elapsed.unwrap_or(0.0) }).collect(),
None => vec![],
}
}
/// When the steps of the current slide's triggered sequences were started.
pub fn trigger_stamps(&self) -> Vec<StepStamp> {
let Some(r) = self.runs.get(&self.slide) else { return vec![] };
r.triggers.iter().flat_map(|t| t.stamps.iter().enumerate().map(|(step, &at)| StepStamp { trigger: t.shape, step, at })).collect()
}
/// Is a triggered animation of the current slide playing (so the picture changes every frame)?
pub fn trigger_playing(&self) -> bool {
self.runs.get(&self.slide).is_some_and(|r| r.triggers.iter().any(|t| t.elapsed.is_some()))
}
/// Animation state of `shape` on the current slide: main step `step` with `t_main` seconds
/// played, plus the triggered sequences.
pub fn anim_state(&self, tl: &Timeline, shape: ShapeId, t_main: f64) -> AnimState {
tl.state_ordered(shape, None, self.step, t_main, &self.trigger_plays(), &self.trigger_stamps())
}
/// Advance the clock of the playing triggered animations to `now` (seconds, any steady clock)
/// and complete the steps that reached their end. [`Click::Animated`] when the picture changed.
pub fn tick(&mut self, tl: &Timeline, now: f64) -> Click {
let dt = match self.last_now {
Some(last) if now.is_finite() => (now - last).clamp(0.0, 3600.0),
_ => 0.0,
};
if now.is_finite() {
self.last_now = Some(now);
}
let Some(run) = self.runs.get_mut(&self.slide) else { return Click::None };
let mut changed = false;
for t in run.triggers.iter_mut() {
let Some(e) = t.elapsed else { continue };
let e = finite_or_zero(e) + dt;
if e >= tl.trigger_step_duration(t.shape, t.done) {
t.done += 1;
t.elapsed = None;
changed = true;
} else {
t.elapsed = Some(e);
}
}
if changed { Click::Animated } else { Click::None }
}
/// Index of the topmost shape a click lands on: the front-most one under the pointer that is
/// shown. A group without an action does not count; its children and the space inside its box
/// are not it.
pub fn top(&self, pres: &Presentation, tl: &Timeline, shapes: &[HitShape], visible: &dyn Fn(ShapeId) -> bool) -> Option<usize> {
let shown = |mut i: usize| -> bool {
// A shape is shown when it and the groups around it are.
for _ in 0..=shapes.len() {
let Some(h) = shapes.get(i) else { return false };
if !visible(h.id) {
return false;
}
match h.parent {
Some(p) => i = p,
None => return true,
}
}
false
};
(0..shapes.len())
.rev()
.find(|&i| shapes.get(i).is_some_and(|h| h.under_pointer && !(h.is_group && !self.interactive(pres, tl, h)) && shown(i)))
}
fn interactive(&self, pres: &Presentation, tl: &Timeline, h: &HitShape) -> bool {
h.link.is_some() || self.trigger_live(pres, tl, h.id)
}
/// The shape under the pointer that a click would act on, `None` when a click would be a click
/// on the background. `visible` tells whether a shape is currently shown (not yet entered or
/// already exited shapes cannot be clicked).
pub fn target(&self, pres: &Presentation, tl: &Timeline, shapes: &[HitShape], visible: &dyn Fn(ShapeId) -> bool) -> Option<Target> {
// The topmost shape, or the group it is in, that has an action. A shape without one blocks
// what lies beneath it.
let mut at = self.top(pres, tl, shapes, visible);
for _ in 0..=shapes.len() {
let h = shapes.get(at?)?;
if self.interactive(pres, tl, h) {
return Some(Target { shape: h.id, link: h.link.clone() });
}
at = h.parent;
}
None
}
/// A mouse click. `shapes` and `visible` describe what is under the pointer (see
/// [`ShowState::target`]); `last_viewed` is the slide shown before this one.
pub fn click(
&mut self,
pres: &Presentation,
tl: &Timeline,
shapes: &[HitShape],
visible: &dyn Fn(ShapeId) -> bool,
now: f64,
last_viewed: Option<usize>,
) -> Click {
self.tick(tl, now);
if self.ended {
return Click::None;
}
let Some(t) = self.target(pres, tl, shapes, visible) else { return self.background_click(pres) };
let mut animated = false;
if self.trigger_live(pres, tl, t.shape) {
let slide = self.slide;
let run = self.runs.entry(slide).or_default();
let at = match run.triggers.iter().position(|r| r.shape == t.shape) {
Some(p) => p,
None => {
run.triggers.push(TriggerRun { shape: t.shape, done: 0, elapsed: None, stamps: vec![] });
run.triggers.len() - 1
}
};
let stamp = run.clock.saturating_add(1);
if let Some(r) = run.triggers.get_mut(at) {
if r.elapsed.is_some() {
// A click on a shape whose step is playing completes the step.
r.done += 1;
r.elapsed = None;
} else {
if r.done >= tl.trigger_steps(t.shape) {
// All steps have played: the sequence starts over.
r.done = 0;
r.stamps.clear();
}
r.stamps.push(stamp);
run.clock = stamp;
if tl.trigger_step_duration(t.shape, r.done) > 0.0 {
r.elapsed = Some(0.0);
} else {
r.done += 1;
}
}
animated = true;
}
}
match t.link {
// 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,
None => Click::None,
}
}
/// A click that is on no shape with an action: next animation step, then next slide, unless the
/// slide has "advance on click" off, which leaves only the animation steps.
fn background_click(&mut self, pres: &Presentation) -> Click {
let advance = pres.slides.get(self.slide).and_then(|s| s.transition.as_ref()).is_none_or(|t| t.advance_on_click);
let steps_left = self.playing || self.step < ShowState::steps(pres, self.slide);
if advance || steps_left { Click::Show(self.next(pres)) } else { Click::None }
}
}
+7 -2
View File
@@ -5,7 +5,8 @@
//! - [`transition_layers`]: a slide transition frame as textured quads of the old/new slide images. //! - [`transition_layers`]: a slide transition frame as textured quads of the old/new slide images.
//! - [`morph_frame`]: a Morph transition frame (shapes matched by [`morph_pairs`], moved by //! - [`morph_frame`]: a Morph transition frame (shapes matched by [`morph_pairs`], moved by
//! [`morph_xfrm`]). //! [`morph_xfrm`]).
//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings). //! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings), what was
//! played on each visited slide, and what a click does ([`ShowState::click`]).
//! //!
//! Pure logic: no rendering, no UI toolkit. Every function accepts hostile numbers (NaN, huge or //! Pure logic: no rendering, no UI toolkit. Every function accepts hostile numbers (NaN, huge or
//! negative times) and never panics. //! negative times) and never panics.
@@ -14,6 +15,7 @@
mod easing; mod easing;
mod effects; mod effects;
mod interact;
mod mesh; mod mesh;
mod morph; mod morph;
mod path; mod path;
@@ -26,17 +28,20 @@ mod transition_geom;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
#[cfg(test)] #[cfg(test)]
mod tests_interact;
#[cfg(test)]
mod transition_tests; mod transition_tests;
pub use deckcraft_model::Rgba; pub use deckcraft_model::Rgba;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub use easing::ease; pub use easing::ease;
pub use interact::{Click, HitShape, Target};
pub use mesh::{MeshVertex, layer_mesh}; pub use mesh::{MeshVertex, layer_mesh};
pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm}; pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm};
pub use path::{MotionPath, default_path}; pub use path::{MotionPath, default_path};
pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order}; pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order};
pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay}; pub use timeline::{EffectTiming, Sequence, StepStamp, Timeline, TriggerPlay};
pub use transition::{DEFAULT_ASPECT, Layer, Source, transition_frame, transition_layers, uses_slide_parts}; pub use transition::{DEFAULT_ASPECT, Layer, Source, transition_frame, transition_layers, uses_slide_parts};
/// Animation state of one shape (or one paragraph of a shape's text) at one moment of the show. /// Animation state of one shape (or one paragraph of a shape's text) at one moment of the show.
+49 -7
View File
@@ -1,10 +1,13 @@
//! Slide show navigation: click steps, slide order (hidden slides, custom shows, ranges), loop //! Slide show navigation: click steps, slide order (hidden slides, custom shows, ranges), loop
//! and automatic advance. //! and automatic advance.
use std::collections::BTreeMap;
use deckcraft_model::Presentation; use deckcraft_model::Presentation;
use deckcraft_model::text::Action; use deckcraft_model::text::Action;
use crate::Timeline; use crate::Timeline;
use crate::interact::SlideRun;
/// What the UI should do after a navigation call. /// What the UI should do after a navigation call.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -52,8 +55,10 @@ pub enum LinkJump {
None, None,
} }
/// Where a slide show is. /// Where a slide show is. Besides the current position it remembers what was played on every slide
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] /// the show has visited (main steps, triggered sequences), so a slide reached again by a link or
/// the arrow keys looks the way it was left, as in PowerPoint.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct ShowState { pub struct ShowState {
/// Index into `presentation.slides`. /// Index into `presentation.slides`.
pub slide: usize, pub slide: usize,
@@ -63,6 +68,12 @@ pub struct ShowState {
pub playing: bool, pub playing: bool,
/// On the end screen. /// On the end screen.
pub ended: bool, pub ended: bool,
/// The show has entered a slide (so `slide` and `step` are meaningful and can be saved).
active: bool,
/// The time of the last [`ShowState::tick`] (seconds), for the triggered animations' clocks.
pub(crate) last_now: Option<f64>,
/// What was played on each visited slide (index into `presentation.slides`).
pub(crate) runs: BTreeMap<usize, SlideRun>,
} }
/// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus /// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus
@@ -120,11 +131,37 @@ impl ShowState {
if pres.show.without_animation { 0 } else { ShowState::timeline(pres, i).steps() } if pres.show.without_animation { 0 } else { ShowState::timeline(pres, i).steps() }
} }
/// Save what was played on the current slide before the show moves away from it. A main step
/// that is playing is taken to its end. Triggered animations keep their place: their clock only
/// runs while the slide is shown, and they go on from there when the show comes back.
fn leave(&mut self, pres: &Presentation) {
if !self.active {
return;
}
let done = if self.playing { self.step + 1 } else { self.step };
let step = done.min(ShowState::steps(pres, self.slide));
self.runs.entry(self.slide).or_default().step = step;
self.playing = false;
}
/// Show slide `i`: as it was left when the show has been there before, otherwise from the start.
fn enter(&mut self, pres: &Presentation, i: usize) { fn enter(&mut self, pres: &Presentation, i: usize) {
self.leave(pres);
self.active = true;
self.slide = i; self.slide = i;
self.step = 0;
self.ended = false; self.ended = false;
self.playing = !pres.show.without_animation && ShowState::timeline(pres, i).step_is_auto(0); // Time spent elsewhere never counts for this slide's triggered animations.
self.last_now = None;
let steps = ShowState::steps(pres, i);
let run = self.runs.entry(i).or_default();
if run.visited {
self.step = run.step.min(steps);
self.playing = false;
} else {
run.visited = true;
self.step = 0;
self.playing = !pres.show.without_animation && ShowState::timeline(pres, i).step_is_auto(0);
}
} }
fn next_index(&self, pres: &Presentation) -> Option<usize> { fn next_index(&self, pres: &Presentation) -> Option<usize> {
@@ -220,9 +257,14 @@ impl ShowState {
}; };
match target { match target {
Some(i) => { Some(i) => {
self.slide = i; // A slide the show has been to looks as it was left; one it has not been to is
self.step = ShowState::steps(pres, i); // shown fully built.
self.playing = false; let seen = self.runs.get(&i).is_some_and(|r| r.visited);
self.enter(pres, i);
if !seen {
self.step = ShowState::steps(pres, i);
self.playing = false;
}
ShowAction::Slide(i) ShowAction::Slide(i)
} }
None => ShowAction::None, None => ShowAction::None,
+364
View File
@@ -0,0 +1,364 @@
//! Clicks, triggered animations and what the show remembers about visited slides.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::Arc;
use deckcraft_model::text::Action;
use deckcraft_model::{AnimClass, AnimStart, Animation, Presentation, Shape, ShapeId, Slide, SlideId, Transition, Xfrm};
use crate::*;
fn shape(id: u32) -> Shape {
Shape { id: ShapeId(id), name: format!("S{id}"), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), ..Default::default() }
}
/// A triggered exit (`Disappear` when `ms` is 0, else a fade out) of `target`, started by clicking `trigger`.
fn exit_on_click(trigger: u32, target: u32, ms: u32) -> Animation {
let effect = if ms == 0 { "disappear" } else { "fadeOut" };
Animation {
shape: ShapeId(target),
class: AnimClass::Exit,
effect: effect.into(),
start: AnimStart::OnClick,
duration_ms: ms,
trigger: Some(ShapeId(trigger)),
..Default::default()
}
}
fn slide(id: u32, shapes: Vec<Shape>, animations: Vec<Animation>, advance_on_click: bool) -> Arc<Slide> {
let transition = (!advance_on_click).then(|| Transition { advance_on_click: false, ..Default::default() });
Arc::new(Slide { id: SlideId(id), shapes, animations, transition, ..Default::default() })
}
fn to_slide(n: u32) -> Option<Action> {
Some(Action::Slide { slide: SlideId(n) })
}
fn hit(id: u32, link: Option<Action>) -> HitShape {
HitShape { id: ShapeId(id), parent: None, is_group: false, link, under_pointer: true }
}
/// Test driver: a show plus the click helpers the slide show window uses.
struct Run {
pres: Presentation,
show: ShowState,
/// Hit shapes of each slide, back to front.
hits: Vec<Vec<HitShape>>,
now: f64,
}
impl Run {
fn new(pres: Presentation, hits: Vec<Vec<HitShape>>) -> Run {
let show = ShowState::new(&pres);
Run { pres, show, hits, now: 0.0 }
}
fn tl(&self) -> Timeline {
ShowState::timeline(&self.pres, self.show.slide)
}
fn visible(&self, id: ShapeId) -> bool {
let st = self.show.anim_state(&self.tl(), id, 0.0);
st.visible && st.opacity > 0.0
}
fn opacity(&self, id: u32) -> f64 {
self.show.anim_state(&self.tl(), ShapeId(id), 0.0).opacity
}
fn click(&mut self) -> Click {
let (tl, hits) = (self.tl(), self.hits[self.show.slide].clone());
let vis: Vec<(ShapeId, bool)> = hits.iter().map(|h| (h.id, self.visible(h.id))).collect();
let visible = move |id: ShapeId| vis.iter().find(|v| v.0 == id).is_none_or(|v| v.1);
self.show.click(&self.pres, &tl, &hits, &visible, self.now, None)
}
fn target(&self) -> Option<Target> {
let (tl, hits) = (self.tl(), &self.hits[self.show.slide]);
self.show.target(&self.pres, &tl, hits, &|id| self.visible(id))
}
/// Let `secs` pass on the shown slide, in small frames.
fn wait(&mut self, secs: f64) {
let end = self.now + secs;
while self.now < end - 1e-9 {
self.now = (self.now + 0.05).min(end);
let tl = self.tl();
self.show.tick(&tl, self.now);
}
}
}
/// The test deck: slide 1 has a text box (3), a linked button that also disappears (2) and a
/// picture on top of both that disappears (4); slide 2 has a button back to slide 1.
fn two_slides(advance_on_click: bool) -> Run {
let s1 = slide(1000, vec![shape(3), shape(2), shape(4)], vec![exit_on_click(4, 4, 0), exit_on_click(2, 2, 0)], advance_on_click);
let s2 = slide(1001, vec![shape(1)], vec![], advance_on_click);
let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
Run::new(pres, vec![vec![hit(3, None), hit(2, to_slide(1001)), hit(4, None)], vec![hit(1, to_slide(1000))]])
}
#[test]
fn hidden_shapes_stay_hidden_when_the_show_comes_back() {
let mut r = two_slides(false);
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(4)), "the picture is on top");
// Click the picture: it disappears; the button beneath it is now the target.
assert_eq!(r.click(), Click::Animated);
assert!(!r.visible(ShapeId(4)));
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(2)));
// Click the button: its animation starts and the link is followed.
assert_eq!(r.click(), Click::Show(ShowAction::Slide(1)));
assert_eq!(r.show.slide, 1);
// Slide 2: the button goes back.
assert_eq!(r.click(), Click::Show(ShowAction::Slide(0)));
assert_eq!(r.show.slide, 0);
// Both are still gone, and nothing on the slide can be clicked any more.
assert!(!r.visible(ShapeId(4)) && !r.visible(ShapeId(2)) && r.visible(ShapeId(3)));
assert_eq!(r.target(), None);
// "Advance on click" is off: a click anywhere does nothing, however often.
for _ in 0..3 {
assert_eq!(r.click(), Click::None);
}
assert_eq!(r.show.slide, 0);
}
#[test]
fn background_click_advances_when_the_slide_allows_it() {
let mut r = two_slides(true);
r.click(); // the picture disappears
r.click(); // the button disappears and links to slide 2
r.click(); // the button there links back
assert_eq!(r.show.slide, 0);
assert_eq!(r.target(), None);
assert_eq!(r.click(), Click::Show(ShowAction::Slide(1)));
}
#[test]
fn advance_on_click_off_still_plays_animation_steps() {
let main = Animation { shape: ShapeId(1), class: AnimClass::Entrance, effect: "fade".into(), duration_ms: 500, ..Default::default() };
let s1 = slide(1000, vec![shape(1)], vec![main], false);
let s2 = slide(1001, vec![], vec![], false);
let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
let mut r = Run::new(pres, vec![vec![hit(1, None)], vec![]]);
// The first click plays the step, the next finishes it; then the slide stays.
assert!(matches!(r.click(), Click::Show(ShowAction::PlayStep(0))));
assert!(matches!(r.click(), Click::Show(ShowAction::FinishStep(0))));
assert_eq!(r.click(), Click::None);
assert_eq!(r.show.slide, 0);
// The arrow keys still move on.
assert_eq!(r.show.next(&r.pres), ShowAction::Slide(1));
}
#[test]
fn a_shape_without_an_action_blocks_the_one_beneath_it() {
let s = slide(1000, vec![shape(1), shape(2)], vec![], true);
let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() };
let mut r = Run::new(pres, vec![vec![hit(1, to_slide(1001)), hit(2, None)], vec![]]);
assert_eq!(r.target(), None, "the plain shape on top blocks the button");
// So the click is a background click, not the button's link.
assert_eq!(r.click(), Click::Show(ShowAction::Slide(1)));
// Reversed, the button is on top and is the target.
let s = slide(1000, vec![shape(2), shape(1)], vec![], true);
let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() };
let r = Run::new(pres, vec![vec![hit(2, None), hit(1, to_slide(1001))], vec![]]);
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1)));
}
#[test]
fn groups_answer_for_their_children() {
let s = slide(1000, vec![shape(1)], vec![], true);
let pres = Presentation { slides: vec![s, slide(1001, vec![], vec![], true)], ..Default::default() };
let group = |link| HitShape { id: ShapeId(10), parent: None, is_group: true, link, under_pointer: true };
let child = HitShape { id: ShapeId(11), parent: Some(1), is_group: false, link: None, under_pointer: true };
// A child of a group with an action acts as the group.
let r = Run::new(pres.clone(), vec![vec![hit(5, None), group(to_slide(1001)), child.clone()], vec![]]);
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(10)));
// Empty space inside the box of a group without an action blocks nothing.
let behind = hit(5, to_slide(1001));
let r = Run::new(pres, vec![vec![behind, group(None)], vec![]]);
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(5)));
}
#[test]
fn pointer_outside_a_shape_is_not_on_it() {
let s = slide(1000, vec![shape(1)], vec![], true);
let pres = Presentation { slides: vec![s], ..Default::default() };
let mut h = hit(1, to_slide(1000));
h.under_pointer = false;
let r = Run::new(pres, vec![vec![h]]);
assert_eq!(r.target(), None);
}
#[test]
fn each_click_plays_one_step_of_a_trigger_and_a_click_completes_a_playing_step() {
// Clicking shape 1 fades shape 2 out over 5 s, then shape 3 over 5 s.
let s = slide(1000, vec![shape(1), shape(2), shape(3)], vec![exit_on_click(1, 2, 5000), exit_on_click(1, 3, 5000)], true);
let pres = Presentation { slides: vec![s], ..Default::default() };
let mut r = Run::new(pres, vec![vec![hit(2, None), hit(3, None), hit(1, None)]]);
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1)));
assert_eq!(r.click(), Click::Animated);
r.wait(2.0);
assert!(r.show.trigger_playing());
assert!(r.opacity(2) < 1.0 && r.opacity(2) > 0.0, "part way through the fade");
assert!(r.visible(ShapeId(3)));
// A click on the shape while its step plays completes the step at once...
assert_eq!(r.click(), Click::Animated);
assert!(!r.visible(ShapeId(2)) && !r.show.trigger_playing());
// ...and the next click plays the next step.
assert_eq!(r.click(), Click::Animated);
r.wait(5.2);
assert!(!r.visible(ShapeId(3)));
// All steps have played, and the shape still answers: its sequence starts over.
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(1)));
assert_eq!(r.click(), Click::Animated);
assert!(r.visible(ShapeId(2)) && r.visible(ShapeId(3)), "the first lap's effects are undone");
r.wait(5.2);
assert!(!r.visible(ShapeId(2)) && r.visible(ShapeId(3)));
}
#[test]
fn a_ring_of_triggers_goes_round_and_round() {
// Soccer's score: clicking shape k hides it and shows shape k+1; the last shows the first again.
let ring = 4u32;
let anims: Vec<Animation> = (1..=ring)
.flat_map(|k| {
let next = k % ring + 1;
let show = Animation {
shape: ShapeId(next),
class: AnimClass::Entrance,
effect: "appear".into(),
start: AnimStart::WithPrevious,
duration_ms: 0,
trigger: Some(ShapeId(k)),
..Default::default()
};
[exit_on_click(k, k, 0), show]
})
.collect();
let shapes: Vec<Shape> = (1..=ring).map(shape).collect();
let s = slide(1000, shapes, anims, true);
let pres = Presentation { slides: vec![s], ..Default::default() };
let hits: Vec<HitShape> = (1..=ring).map(|k| hit(k, None)).collect();
let mut r = Run::new(pres, vec![hits]);
let showing = |r: &Run| -> Vec<u32> { (1..=ring).filter(|k| r.visible(ShapeId(*k))).collect() };
assert_eq!(showing(&r), vec![1]);
for lap in 0..3 {
for k in 1..=ring {
assert_eq!(showing(&r), vec![k], "lap {lap}, before click {k}");
assert_eq!(r.target().map(|t| t.shape), Some(ShapeId(k)));
assert_eq!(r.click(), Click::Animated);
}
}
assert_eq!(showing(&r), vec![1]);
}
#[test]
fn a_link_is_followed_at_once_and_the_animation_goes_on_where_it_stopped() {
// Shape 1 fades shape 4 over 5 s; shape 2 links to slide 2.
let s1 = slide(1000, vec![shape(1), shape(2), shape(4)], vec![exit_on_click(1, 4, 5000)], false);
let s2 = slide(1001, vec![shape(5)], vec![], false);
let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
let mut r = Run::new(pres, vec![vec![hit(4, None), hit(2, to_slide(1001)), hit(1, None)], vec![hit(5, to_slide(1000))]]);
// Click the animated shape, wait 2 s, click the link.
r.click();
r.wait(2.0);
assert_eq!(r.show.trigger_plays()[0].t.round(), 2.0);
// The second shape is not over the first one, so click the link directly.
let tl = r.tl();
let hits = vec![hit(2, to_slide(1001))];
assert_eq!(r.show.click(&r.pres, &tl, &hits, &|_| true, r.now, None), Click::Show(ShowAction::Slide(1)));
// However long the show stays away, the animation does not run.
r.wait(60.0);
assert_eq!(r.click(), Click::Show(ShowAction::Slide(0)));
// Back on the slide it has 3 s left.
assert!((r.show.trigger_plays()[0].t - 2.0).abs() < 0.1);
assert!(r.show.trigger_playing());
r.wait(2.5);
assert!(r.show.trigger_playing() && r.opacity(4) > 0.0);
r.wait(1.0);
assert!(!r.show.trigger_playing() && !r.visible(ShapeId(4)));
}
#[test]
fn a_shape_with_a_link_and_an_animation_follows_the_link_without_waiting() {
let s1 = slide(1000, vec![shape(2)], vec![exit_on_click(2, 2, 5000)], false);
let s2 = slide(1001, vec![shape(5)], vec![], false);
let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
let mut r = Run::new(pres, vec![vec![hit(2, to_slide(1001))], vec![hit(5, to_slide(1000))]]);
assert_eq!(r.click(), Click::Show(ShowAction::Slide(1)));
r.wait(30.0);
assert_eq!(r.click(), Click::Show(ShowAction::Slide(0)));
// The whole animation is still ahead.
assert!(r.visible(ShapeId(2)) && r.opacity(2) > 0.95);
assert!(r.show.trigger_playing());
r.wait(5.5);
assert!(!r.visible(ShapeId(2)));
}
#[test]
fn a_slide_comes_back_the_way_it_was_left() {
let steps: Vec<Animation> = (1..=3)
.map(|i| Animation { shape: ShapeId(i), class: AnimClass::Entrance, effect: "appear".into(), duration_ms: 0, ..Default::default() })
.collect();
let s1 = slide(1000, vec![shape(1), shape(2), shape(3)], steps, true);
let s2 = slide(1001, vec![], vec![], true);
let s3 = slide(1002, vec![], vec![], true);
let pres = Presentation { slides: vec![s1, s2, s3], ..Default::default() };
let mut s = ShowState::new(&pres);
s.next(&pres);
s.step_done(&pres);
assert_eq!(s.step, 1);
// A link away and back: still after step 1.
assert_eq!(s.follow(&pres, &Action::Slide { slide: SlideId(1002) }, None), LinkJump::Show(ShowAction::Slide(2)));
assert_eq!(s.follow(&pres, &Action::Slide { slide: SlideId(1000) }, None), LinkJump::Show(ShowAction::Slide(0)));
assert_eq!((s.step, s.playing), (1, false));
// So does the back arrow.
s.goto(&pres, 1);
assert_eq!(s.prev(&pres), ShowAction::Slide(0));
assert_eq!(s.step, 1);
// A slide the show has not been to is entered at its start; going back to one shows it fully
// built.
let mut s = ShowState::new(&pres);
s.goto(&pres, 2);
assert_eq!(s.prev(&pres), ShowAction::Slide(1));
assert_eq!(s.step, ShowState::steps(&pres, 1));
}
#[test]
fn a_playing_main_step_is_finished_when_the_show_leaves() {
let a = Animation { shape: ShapeId(1), class: AnimClass::Entrance, effect: "fade".into(), duration_ms: 500, ..Default::default() };
let s1 = slide(1000, vec![shape(1)], vec![a], true);
let s2 = slide(1001, vec![], vec![], true);
let pres = Presentation { slides: vec![s1, s2], ..Default::default() };
let mut s = ShowState::new(&pres);
assert_eq!(s.next(&pres), ShowAction::PlayStep(0));
s.goto(&pres, 1);
s.goto(&pres, 0);
assert_eq!((s.step, s.playing), (1, false));
}
#[test]
fn show_without_animation_ignores_triggers() {
let s = slide(1000, vec![shape(1), shape(2)], vec![exit_on_click(1, 2, 0)], true);
let mut pres = Presentation { slides: vec![s], ..Default::default() };
pres.show.without_animation = true;
let r = Run::new(pres, vec![vec![hit(2, None), hit(1, None)]]);
assert_eq!(r.target(), None);
}
#[test]
fn clocks_and_pointers_with_hostile_numbers_are_harmless() {
let mut r = two_slides(true);
let tl = r.tl();
for now in [f64::NAN, f64::INFINITY, -1.0e300, 1.0e300, 0.0] {
r.show.tick(&tl, now);
let _ = r.show.click(&r.pres, &tl, &[], &|_| true, now, None);
}
// A parent index that points nowhere, or in a circle, is not followed forever.
let bad = vec![
HitShape { id: ShapeId(1), parent: Some(7), is_group: true, link: None, under_pointer: true },
HitShape { id: ShapeId(2), parent: Some(2), is_group: false, link: None, under_pointer: true },
];
assert_eq!(r.show.target(&r.pres, &tl, &bad, &|_| true), None);
}
+35 -1
View File
@@ -57,6 +57,16 @@ pub struct TriggerPlay {
pub t: f64, pub t: f64,
} }
/// When a step of a triggered sequence was started, counted in started steps on the slide. Effects
/// of different triggers apply in this order, so that a shape hidden by one trigger and shown again
/// by another ends up as the later of the two left it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StepStamp {
pub trigger: ShapeId,
pub step: usize,
pub at: u64,
}
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
struct StepInfo { struct StepInfo {
auto: bool, auto: bool,
@@ -379,6 +389,21 @@ impl Timeline {
/// [`Timeline::state`] with the playheads of triggered sequences. /// [`Timeline::state`] with the playheads of triggered sequences.
pub fn state_with(&self, shape: ShapeId, para: Option<usize>, step: usize, t: f64, triggers: &[TriggerPlay]) -> AnimState { pub fn state_with(&self, shape: ShapeId, para: Option<usize>, step: usize, t: f64, triggers: &[TriggerPlay]) -> AnimState {
self.state_ordered(shape, para, step, t, triggers, &[])
}
/// [`Timeline::state_with`] applying the effects of triggered sequences in the order their steps
/// were started (`stamps`), after those of the main sequence. Without stamps they apply in
/// Animation Pane order.
pub fn state_ordered(
&self,
shape: ShapeId,
para: Option<usize>,
step: usize,
t: f64,
triggers: &[TriggerPlay],
stamps: &[StepStamp],
) -> AnimState {
let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p)); let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p));
let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para); let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para);
let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82)); let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
@@ -391,7 +416,16 @@ impl Timeline {
{ {
st.visible = false; st.visible = false;
} }
// Then play in time order: main sequence first, then triggered sequences. // Then play in time order: main sequence first, then triggered sequences as they were clicked.
if !stamps.is_empty() {
let when = |e: &Effect| match e.timing.sequence {
Sequence::Main => 0,
Sequence::Trigger(tr) => {
stamps.iter().find(|s| s.trigger == tr && s.step == e.timing.step).map_or(u64::MAX, |s| s.at.saturating_add(1))
}
};
ordered.sort_by_key(|e| (when(e), e.timing.anim));
}
for e in &ordered { for e in &ordered {
let (pstep, pt) = match e.timing.sequence { let (pstep, pt) = match e.timing.sequence {
Sequence::Main => (step, t), Sequence::Main => (step, t),
+3
View File
@@ -25,3 +25,6 @@ i_overlay = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
log = { workspace = true } log = { workspace = true }
image = { workspace = true } image = { workspace = true }
[dev-dependencies]
deckcraft-anim = { workspace = true }
+236
View File
@@ -0,0 +1,236 @@
//! Slide show clicks on decks that went through a PPTX file: links, click-triggered animations and
//! what a slide looks like when the show comes back to it.
use std::sync::Arc;
use deckcraft_anim::{Click, HitShape, ShowAction, ShowState};
use deckcraft_geom::Xfrm;
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, Transition};
use deckcraft_model::text::{Action, Hyperlink, TextBody};
use deckcraft_model::{LayoutType, Presentation, Shape, ShapeId, SlideId, defaults};
fn shape(p: &mut Presentation, name: &str, text: &str, x: f64, y: f64, w: f64, h: f64) -> Shape {
Shape {
id: ShapeId(p.alloc_id()),
name: name.into(),
xfrm: Some(Xfrm::new(x, y, w, h)),
text: Some(TextBody::from_text(text)),
..Default::default()
}
}
fn link_to(slide: SlideId) -> Option<Hyperlink> {
Some(Hyperlink { action: Action::Slide { slide }, tooltip: String::new(), highlight_click: false })
}
/// Clicking `trigger` makes `target` disappear.
fn disappear_on_click(trigger: ShapeId, target: ShapeId) -> Animation {
Animation {
shape: target,
class: AnimClass::Exit,
effect: "disappear".into(),
start: AnimStart::OnClick,
duration_ms: 0,
trigger: Some(trigger),
..Default::default()
}
}
fn add_slide(p: &mut Presentation, shapes: Vec<Shape>, animations: Vec<Animation>, advance_on_click: bool) -> SlideId {
let layout = defaults::layout_of_kind(p, LayoutType::Blank).unwrap_or_default();
let mut s = defaults::new_slide(p, layout);
s.shapes = shapes;
s.animations = animations;
s.transition = (!advance_on_click).then(|| Transition { advance_on_click: false, ..Default::default() });
let id = s.id;
p.slides.push(Arc::new(s));
id
}
/// Drops the slide `new_presentation` starts with.
fn start(p: &mut Presentation) {
p.slides.clear();
}
/// The slide show window's click handling, on a deck read from a PPTX file.
struct Show {
pres: Presentation,
state: ShowState,
}
impl Show {
fn open(p: &Presentation) -> Show {
let bytes = deckcraft_pptx::export(p).expect("export");
let pres = deckcraft_pptx::import(&bytes).expect("import");
let state = ShowState::new(&pres);
Show { pres, state }
}
fn hits(&self, x: f64, y: f64) -> (Vec<HitShape>, Vec<bool>) {
let placed = deckcraft_render::place_slide(&self.pres, self.state.slide);
let tl = ShowState::timeline(&self.pres, self.state.slide);
let hits = placed
.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: s.rect.x0 <= x && x <= s.rect.x1 && s.rect.y0 <= y && y <= s.rect.y1,
})
.collect();
let visible = placed
.shapes
.iter()
.map(|s| {
let a = self.state.anim_state(&tl, s.id, 0.0);
a.visible && a.opacity > 0.0
})
.collect();
(hits, visible)
}
/// Click the middle of the shape called `name` on the current slide.
fn click(&mut self, name: &str) -> Click {
let (x, y) = self.centre(name);
let (hits, vis) = self.hits(x, y);
let tl = ShowState::timeline(&self.pres, self.state.slide);
let visible = |id: ShapeId| hits.iter().zip(&vis).find(|(h, _)| h.id == id).is_none_or(|(_, v)| *v);
self.state.click(&self.pres, &tl, &hits, &visible, 0.0, None)
}
fn centre(&self, name: &str) -> (f64, f64) {
let slide = &self.pres.slides[self.state.slide];
let s = slide.shapes.iter().find(|s| s.name == name).unwrap_or_else(|| panic!("no shape {name}"));
let x = s.xfrm.expect("xfrm");
(x.x + x.w / 2.0, x.y + x.h / 2.0)
}
fn shown(&self, name: &str) -> bool {
let slide = &self.pres.slides[self.state.slide];
let Some(s) = slide.shapes.iter().find(|s| s.name == name) else { return false };
let a = self.state.anim_state(&ShowState::timeline(&self.pres, self.state.slide), s.id, 0.0);
a.visible && a.opacity > 0.0
}
fn is_target(&self, name: &str) -> bool {
let (x, y) = self.centre(name);
let (hits, vis) = self.hits(x, y);
let tl = ShowState::timeline(&self.pres, self.state.slide);
let visible = |id: ShapeId| hits.iter().zip(&vis).find(|(h, _)| h.id == id).is_none_or(|(_, v)| *v);
let id = self.pres.slides[self.state.slide].shapes.iter().find(|s| s.name == name).map(|s| s.id);
self.state.target(&self.pres, &tl, &hits, &visible).map(|t| t.shape) == id
}
}
/// An image that disappears when clicked, revealing a button that links to slide 2 and disappears
/// as well; slide 2 links back; the slides do not advance on a click.
fn hide_and_reveal_deck() -> Presentation {
let mut p = defaults::new_presentation(None);
start(&mut p);
let s2_id = SlideId(p.alloc_id());
let _ = s2_id;
let message = shape(&mut p, "Message", "Both hidden", 100.0, 100.0, 600.0, 300.0);
let mut button = shape(&mut p, "Button", "Image hidden", 100.0, 100.0, 600.0, 300.0);
let picture = shape(&mut p, "Picture", "Picture", 100.0, 100.0, 600.0, 300.0);
let animations = vec![disappear_on_click(picture.id, picture.id), disappear_on_click(button.id, button.id)];
let first = add_slide(&mut p, vec![message, button.clone(), picture], animations, false);
let mut back = shape(&mut p, "Back", "Go back", 100.0, 100.0, 600.0, 300.0);
back.click = link_to(first);
let second = add_slide(&mut p, vec![back], vec![], false);
// The button links to slide 2.
let s = Arc::make_mut(&mut p.slides[0]);
button.click = link_to(second);
if let Some(b) = s.shapes.iter_mut().find(|x| x.name == "Button") {
b.click = button.click.clone();
}
p
}
#[test]
fn hidden_shapes_stay_hidden_and_dead_when_the_show_comes_back() {
let mut show = Show::open(&hide_and_reveal_deck());
assert!(show.is_target("Picture") && show.shown("Picture"));
assert_eq!(show.click("Picture"), Click::Animated);
assert!(!show.shown("Picture"));
// The button was under the picture; now it is on top. Its click follows the link.
assert!(show.is_target("Button"));
assert_eq!(show.click("Button"), Click::Show(ShowAction::Slide(1)));
assert_eq!(show.state.slide, 1);
assert_eq!(show.click("Back"), Click::Show(ShowAction::Slide(0)));
// Back on slide 1: both are gone, only the message is left, and it does nothing.
assert!(!show.shown("Picture") && !show.shown("Button") && show.shown("Message"));
assert!(!show.is_target("Message"));
for _ in 0..3 {
assert_eq!(show.click("Message"), Click::None);
}
assert_eq!(show.state.slide, 0);
}
/// A board of three tiles, each linking to its question, which links back; the tile hides itself.
#[test]
fn a_game_board_hides_the_tiles_already_played() {
let mut p = defaults::new_presentation(None);
start(&mut p);
let mut tiles: Vec<Shape> = (0..3).map(|i| shape(&mut p, &format!("Tile {i}"), "100", 50.0 + 250.0 * i as f64, 100.0, 200.0, 100.0)).collect();
let animations: Vec<Animation> = tiles.iter().map(|t| disappear_on_click(t.id, t.id)).collect();
let board = add_slide(&mut p, tiles.clone(), animations, true);
let questions: Vec<SlideId> = (0..3)
.map(|i| {
let mut back = shape(&mut p, "Back", &format!("Question {i}"), 100.0, 100.0, 400.0, 200.0);
back.click = link_to(board);
add_slide(&mut p, vec![back], vec![], true)
})
.collect();
let s = Arc::make_mut(&mut p.slides[0]);
for (t, q) in tiles.iter_mut().zip(&questions) {
if let Some(x) = s.shapes.iter_mut().find(|x| x.id == t.id) {
x.click = link_to(*q);
}
}
let mut show = Show::open(&p);
for (i, played) in [1usize, 0, 2].into_iter().enumerate() {
assert_eq!(show.click(&format!("Tile {played}")), Click::Show(ShowAction::Slide(1 + played)));
assert_eq!(show.click("Back"), Click::Show(ShowAction::Slide(0)));
// Exactly the tiles played so far are gone.
let gone: Vec<bool> = (0..3).map(|t| !show.shown(&format!("Tile {t}"))).collect();
assert_eq!(gone.iter().filter(|g| **g).count(), i + 1, "{gone:?}");
assert!(gone[played]);
assert!(!show.is_target(&format!("Tile {played}")));
}
}
/// A score that cycles: each digit hides itself when clicked and shows the next; the last shows the
/// first again. Every digit has its own one-click trigger.
#[test]
fn a_score_ring_goes_round() {
let mut p = defaults::new_presentation(None);
start(&mut p);
let n = 4usize;
let digits: Vec<Shape> = (0..n).map(|i| shape(&mut p, &format!("Score {i}"), &i.to_string(), 400.0, 100.0, 100.0, 100.0)).collect();
let mut anims = vec![];
for (i, d) in digits.iter().enumerate() {
anims.push(disappear_on_click(d.id, d.id));
anims.push(Animation {
shape: digits[(i + 1) % n].id,
class: AnimClass::Entrance,
effect: "appear".into(),
start: AnimStart::WithPrevious,
duration_ms: 0,
trigger: Some(d.id),
..Default::default()
});
}
add_slide(&mut p, digits, anims, true);
let mut show = Show::open(&p);
let showing = |show: &Show| -> Vec<usize> { (0..n).filter(|i| show.shown(&format!("Score {i}"))).collect() };
assert_eq!(showing(&show), vec![0]);
for click in 0..2 * n {
let now = click % n;
assert_eq!(showing(&show), vec![now], "before click {click}");
assert!(show.is_target(&format!("Score {now}")));
assert_eq!(show.click(&format!("Score {now}")), Click::Animated);
}
assert_eq!(showing(&show), vec![0]);
}
+1 -1
View File
@@ -49,7 +49,7 @@ pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent};
pub use images::{decode as decode_image, gif_frame}; pub use images::{decode as decode_image, gif_frame};
pub use morph_path::PathMorph; pub use morph_path::PathMorph;
pub use morph_text::TextMorph; pub use morph_text::TextMorph;
pub use placed::{Placed, PlacedLink, PlacedText, place_slide}; pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_slide};
pub use table::row_heights as table_row_heights; pub use table::row_heights as table_row_heights;
pub use transition::{SlideParts, composite_transition, render_transition}; pub use transition::{SlideParts, composite_transition, render_transition};
+42 -11
View File
@@ -3,7 +3,7 @@
use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::text::{Hyperlink, TextBody}; use deckcraft_model::text::{Hyperlink, TextBody};
use deckcraft_model::{Presentation, Shape, ShapeKind}; use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
use deckcraft_text::{Opts, TextLayout}; use deckcraft_text::{Opts, TextLayout};
use kurbo::{Affine, Rect}; use kurbo::{Affine, Rect};
@@ -22,12 +22,32 @@ pub struct PlacedLink {
/// Slide-space bounding box. /// Slide-space bounding box.
pub rect: Rect, pub rect: Rect,
pub link: Hyperlink, pub link: Hyperlink,
/// The shape that owns the link (for a text hyperlink, the shape holding the text).
pub shape: ShapeId,
/// A hyperlink on text, not the shape's own action.
pub text: bool,
}
/// A shape of the slide itself (not of its layout or master) with its slide-space bounding box:
/// what a click in a slide show lands on.
pub struct PlacedShape {
pub id: ShapeId,
/// Index into [`Placed::shapes`] of the group this shape is in.
pub parent: Option<usize>,
pub is_group: bool,
/// Slide-space bounding box.
pub rect: Rect,
/// The shape's own action (Insert ▸ Action), not its text hyperlinks.
pub link: Option<Hyperlink>,
} }
#[derive(Default)] #[derive(Default)]
pub struct Placed { pub struct Placed {
pub texts: Vec<PlacedText>, pub texts: Vec<PlacedText>,
pub links: Vec<PlacedLink>, pub links: Vec<PlacedLink>,
/// The slide's own shapes in drawing order (back to front; a group before its children). Hidden
/// shapes are left out.
pub shapes: Vec<PlacedShape>,
} }
/// Text and links of slide `index` (master and layout graphics first, as rendered). /// Text and links of slide `index` (master and layout graphics first, as rendered).
@@ -40,22 +60,25 @@ pub fn place_slide(pres: &Presentation, index: usize) -> Placed {
if show_master { if show_master {
let mctx = Ctx::for_master(pres, rctx.master); let mctx = Ctx::for_master(pres, rctx.master);
for s in rctx.master.shapes.iter().filter(|s| s.ph.is_none()) { for s in rctx.master.shapes.iter().filter(|s| s.ph.is_none()) {
place(&mut out, &mctx, &fields, s, Affine::IDENTITY, 0); place(&mut out, &mctx, &fields, s, Affine::IDENTITY, 0, None, false);
} }
} }
if show_layout && let Some(l) = rctx.layout { if show_layout && let Some(l) = rctx.layout {
let lctx = Ctx::for_layout(pres, rctx.master, l); let lctx = Ctx::for_layout(pres, rctx.master, l);
for s in l.shapes.iter().filter(|s| s.ph.is_none()) { for s in l.shapes.iter().filter(|s| s.ph.is_none()) {
place(&mut out, &lctx, &fields, s, Affine::IDENTITY, 0); place(&mut out, &lctx, &fields, s, Affine::IDENTITY, 0, None, false);
} }
} }
for s in &slide.shapes { for s in &slide.shapes {
place(&mut out, &rctx, &fields, s, Affine::IDENTITY, 0); place(&mut out, &rctx, &fields, s, Affine::IDENTITY, 0, None, true);
} }
out out
} }
fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: Affine, depth: usize) { /// `group` is the index in `out.shapes` of the enclosing group; `own` is true for the slide's own
/// shapes (recorded in `out.shapes`), false for the layout's and master's.
#[allow(clippy::too_many_arguments)]
fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: Affine, depth: usize, group: Option<usize>, own: bool) {
if depth > 32 || s.hidden { if depth > 32 || s.hidden {
return; return;
} }
@@ -64,15 +87,22 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
return; return;
} }
let m = parent * x.affine(); let m = parent * x.affine();
let rect = m.transform_rect_bbox(Rect::new(0.0, 0.0, x.w, x.h));
if let Some(link) = &s.click { if let Some(link) = &s.click {
out.links.push(PlacedLink { rect: m.transform_rect_bbox(Rect::new(0.0, 0.0, x.w, x.h)), link: link.clone() }); out.links.push(PlacedLink { rect, link: link.clone(), shape: s.id, text: false });
} }
let me = if own {
out.shapes.push(PlacedShape { id: s.id, parent: group, is_group: matches!(s.kind, ShapeKind::Group { .. }), rect, link: s.click.clone() });
Some(out.shapes.len() - 1)
} else {
None
};
match &s.kind { match &s.kind {
ShapeKind::Group { children, child } => { ShapeKind::Group { children, child } => {
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
let gm = parent * deckcraft_geom::group_child_affine(&x, ch); let gm = parent * deckcraft_geom::group_child_affine(&x, ch);
for c in children { for c in children {
place(out, rctx, fields, c, gm, depth + 1); place(out, rctx, fields, c, gm, depth + 1, me, own);
} }
} }
ShapeKind::Table(t) => { ShapeKind::Table(t) => {
@@ -81,20 +111,21 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else { continue }; let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else { continue };
let body = table::cell_body(rctx, t, r, c, cell); let body = table::cell_body(rctx, t, r, c, cell);
let tmp = Shape { text: Some(body.clone()), ..Default::default() }; let tmp = Shape { text: Some(body.clone()), ..Default::default() };
place_text(out, rctx, fields, &tmp, &body, rect, m); place_text(out, rctx, fields, s.id, &tmp, &body, rect, m);
} }
} }
ShapeKind::Chart(_) | ShapeKind::Ink { .. } => {} ShapeKind::Chart(_) | ShapeKind::Ink { .. } => {}
_ => { _ => {
let Some(body) = s.text.as_ref().filter(|b| !b.is_empty()) else { return }; let Some(body) = s.text.as_ref().filter(|b| !b.is_empty()) else { return };
let tr = shape_geometry(s, x.w, x.h).text_rect; let tr = shape_geometry(s, x.w, x.h).text_rect;
place_text(out, rctx, fields, s, body, tr, m); place_text(out, rctx, fields, s.id, s, body, tr, m);
} }
} }
} }
/// Lay out `body` in text rect `tr` of shape `s` (shape-local → slide `m`) and record it and its hyperlinks. /// Lay out `body` in text rect `tr` of shape `s` (shape-local → slide `m`) and record it and its hyperlinks.
fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, body: &TextBody, tr: Rect, m: Affine) { #[allow(clippy::too_many_arguments)]
fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId, s: &Shape, body: &TextBody, tr: Rect, m: Affine) {
let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false }); let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false });
let m = crate::text_transform(m, tr, layout.rotation); let m = crate::text_transform(m, tr, layout.rotation);
// Text hyperlinks: one box per line segment of a linked run. // Text hyperlinks: one box per line segment of a linked run.
@@ -105,7 +136,7 @@ fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, bod
let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0); let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
let (x0, x1) = line.span(run.chars.0, run.chars.1).unwrap_or((first, first)); let (x0, x1) = line.span(run.chars.0, run.chars.1).unwrap_or((first, first));
let r = Rect::new(x0, line.top, x1, line.bottom); let r = Rect::new(x0, line.top, x1, line.bottom);
out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link }); out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link, shape: owner, text: true });
} }
out.texts.push(PlacedText { layout, transform: m, body: body.clone() }); out.texts.push(PlacedText { layout, transform: m, body: body.clone() });
} }
+51
View File
@@ -1711,3 +1711,54 @@ fn east_asian_vertical_text_stands_ideographs_upright() {
let (w, h) = ink(deckcraft_model::text::TextDir::Vertical); let (w, h) = ink(deckcraft_model::text::TextDir::Vertical);
assert!(h > w * 3, "vert: stroke on its side {w}x{h}"); assert!(h > w * 3, "vert: stroke on its side {w}x{h}");
} }
#[test]
fn placed_shapes_give_the_click_boxes_of_a_slide_in_drawing_order() {
use deckcraft_model::text::{Action, Hyperlink};
let link = Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: false };
let rect = |id: u32, x: f64, click: Option<Hyperlink>| Shape {
id: ShapeId(id),
xfrm: Some(Xfrm::new(x, 50.0, 100.0, 40.0)),
click,
..Default::default()
};
let group = Shape {
id: ShapeId(10),
xfrm: Some(Xfrm::new(300.0, 50.0, 200.0, 40.0)),
click: Some(link.clone()),
kind: ShapeKind::Group { children: vec![rect(11, 0.0, None), rect(12, 100.0, None)], child: Xfrm::new(0.0, 0.0, 200.0, 40.0) },
..Default::default()
};
let hidden = Shape { hidden: true, ..rect(13, 0.0, Some(link.clone())) };
let p = deck_with(vec![rect(1, 0.0, None), rect(2, 150.0, Some(link.clone())), hidden, group]);
let placed = place_slide(&p, 0);
// Back to front, a group before its children; the hidden shape is not there.
let ids: Vec<u32> = placed.shapes.iter().map(|s| s.id.0).collect();
assert_eq!(ids, vec![1, 2, 10, 11, 12]);
// Only the shapes with an action carry a link.
let linked: Vec<u32> = placed.shapes.iter().filter(|s| s.link.is_some()).map(|s| s.id.0).collect();
assert_eq!(linked, vec![2, 10]);
// The children know their group, whose box covers them.
let group = &placed.shapes[2];
assert!(group.is_group && group.parent.is_none());
for child in &placed.shapes[3..] {
assert_eq!(child.parent, Some(2));
assert!(child.rect.x0 >= group.rect.x0 - 0.01 && child.rect.x1 <= group.rect.x1 + 0.01);
}
// Shape actions come out as links owned by their shape, not as text links.
assert!(placed.links.iter().all(|l| !l.text));
assert_eq!(placed.links.iter().map(|l| l.shape.0).collect::<Vec<_>>(), vec![2, 10]);
}
#[test]
fn a_text_hyperlink_belongs_to_the_shape_that_holds_the_text() {
use deckcraft_model::text::{Action, Hyperlink};
let mut body = TextBody::from_text("click here");
for r in &mut body.paragraphs[0].runs {
r.props.link = Some(Hyperlink { action: Action::FirstSlide, tooltip: String::new(), highlight_click: false });
}
let sh = Shape { id: ShapeId(7), xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 60.0)), text: Some(body), ..Default::default() };
let placed = place_slide(&deck_with(vec![sh]), 0);
assert!(placed.links.iter().any(|l| l.text && l.shape == ShapeId(7)));
assert!(placed.shapes.iter().all(|s| s.link.is_none()), "a text link is not the shape's own action");
}
+129 -24
View File
@@ -1,10 +1,10 @@
//! Slide Show: full-screen playback with transitions and animations (`deckcraft-anim`), //! Slide Show: full-screen playback with transitions and animations (`deckcraft-anim`),
//! keyboard/mouse navigation, pen, black/white screens, presenter view and rehearsed timings. //! keyboard/mouse navigation, pen, black/white screens, presenter view and rehearsed timings.
use deckcraft_anim::{Layer, LinkJump, ShowAction, ShowState, Source, Timeline}; use deckcraft_anim::{Click, HitShape, Layer, LinkJump, ShowAction, ShowState, Source, StepStamp, Timeline, TriggerPlay};
use deckcraft_model::text::Hyperlink; use deckcraft_model::text::Hyperlink;
use deckcraft_model::{Presentation, ShapeId}; use deckcraft_model::{Presentation, ShapeId};
use deckcraft_render::{ParaState, RenderOpts, ShapeState}; use deckcraft_render::{ParaState, PlacedLink, PlacedShape, RenderOpts, ShapeState};
use egui::{Align2, Color32, CornerRadius, Mesh, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2}; use egui::{Align2, Color32, CornerRadius, Mesh, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2};
use serde_json::json; use serde_json::json;
@@ -40,8 +40,9 @@ pub struct Show {
media_slide: Option<usize>, media_slide: Option<usize>,
/// The slide shown before the current one (the Last Slide Viewed action). /// The slide shown before the current one (the Last Slide Viewed action).
last_viewed: Option<usize>, last_viewed: Option<usize>,
/// Click areas of hyperlinks and shape actions on slide `.0`, in slide points. /// Click areas on slide `.0`, in slide points: hyperlinks and shape actions, and the slide's
links: Option<(usize, Vec<deckcraft_render::PlacedLink>)>, /// shapes in drawing order.
links: Option<(usize, Vec<PlacedLink>, Vec<PlacedShape>)>,
} }
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
@@ -52,6 +53,8 @@ struct Key {
doc: usize, doc: usize,
/// Media hidden while not playing. /// Media hidden while not playing.
hidden: Vec<ShapeId>, hidden: Vec<ShapeId>,
/// Clicks played of each triggered animation (what the shapes look like when none is running).
plays: Vec<(ShapeId, usize)>,
/// The frames animated GIFs show. /// The frames animated GIFs show.
gif: u64, gif: u64,
} }
@@ -169,7 +172,9 @@ impl Show {
/// On entering a slide: auto-play its first step when it begins With/After Previous. /// On entering a slide: auto-play its first step when it begins With/After Previous.
fn after_enter(&mut self, doc: &Presentation, now: f64) { fn after_enter(&mut self, doc: &Presentation, now: f64) {
let auto = self.timeline(doc).step_is_auto(0); // A slide the show comes back to is not started again: only a first visit has an automatic
// first step.
let auto = self.timeline(doc).step_is_auto(0) && self.state.step == 0;
if auto || self.autoplay { if auto || self.autoplay {
let a = self.state.next(doc); let a = self.state.next(doc);
if matches!(a, ShowAction::PlayStep(_)) { if matches!(a, ShowAction::PlayStep(_)) {
@@ -179,8 +184,8 @@ impl Show {
} }
} }
fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeState { fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64, plays: &[TriggerPlay], stamps: &[StepStamp]) -> ShapeState {
let a = tl.state(id, None, step, t); let a = tl.state_ordered(id, None, step, t, plays, stamps);
ShapeState { ShapeState {
visible: a.visible, visible: a.visible,
opacity: a.opacity, opacity: a.opacity,
@@ -314,15 +319,18 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
_ => {} _ => {}
} }
} }
// The clock of the triggered animations runs before anything reacts to the pointer.
show.tick_triggers(&doc, now);
let on_slide = !show.pen && !show.ended && show.blank.is_none() && !show.reading_bar_hit(&ctx); let on_slide = !show.pen && !show.ended && show.blank.is_none() && !show.reading_bar_hit(&ctx);
let gif_click = if clicked && on_slide { gif_hit(&ctx, ui, &show, &doc, app) } else { None }; let gif_click = if clicked && on_slide { gif_hit(&ctx, ui, &show, &doc, app) } else { None };
let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None }; let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None };
let link = if on_slide && gif_click.is_none() && media_click.is_none() { link_hit(&ctx, ui, &mut show, &doc) } else { None }; let under = if on_slide && gif_click.is_none() && media_click.is_none() { shapes_under_pointer(&ctx, ui, &mut show, &doc, now) } else { None };
let pointing = under.as_ref().is_some_and(|u| u.pick.is_some());
if let Some(id) = gif_click { if let Some(id) = gif_click {
let _ = app.run("media.gifPlay", json!({"id": id})); let _ = app.run("media.gifPlay", json!({"id": id}));
} else if let Some((id, clip)) = media_click { } else if let Some((id, clip)) = media_click {
app.media.toggle(&doc, id, &clip, crate::media::Owner::Show); app.media.toggle(&doc, id, &clip, crate::media::Owner::Show);
} else if clicked && let Some(l) = &link { } else if clicked && let Some(Pick::Text(l)) = under.as_ref().and_then(|u| u.pick.clone()) {
let last = show.last_viewed; let last = show.last_viewed;
match show.state.follow(&doc, &l.action, last) { match show.state.follow(&doc, &l.action, last) {
LinkJump::Show(a) => actions.push(a), LinkJump::Show(a) => actions.push(a),
@@ -336,7 +344,14 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
} else if show.blank.is_some() { } else if show.blank.is_some() {
show.blank = None; show.blank = None;
} else { } else {
actions.push(show.state.next(&doc)); // A shape with an action or a triggered animation, or the background.
let none = Under::default();
match show.click_slide(&doc, under.as_ref().unwrap_or(&none), now) {
Click::Show(a) => actions.push(a),
Click::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
Click::Exit => keep = false,
Click::Animated | Click::None => {}
}
} }
} }
if secondary && !show.ended { if secondary && !show.ended {
@@ -395,8 +410,11 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
} else { } else {
let idx = show.state.slide; let idx = show.state.slide;
let step = show.state.step; let step = show.state.step;
let animating = show.state.playing; // A main step or a triggered animation is running: the picture changes every frame.
let t = if animating { now - show.step_start } else { 0.0 }; let animating = show.state.playing || show.state.trigger_playing();
let t = if show.state.playing { now - show.step_start } else { 0.0 };
let plays = show.state.trigger_plays();
let stamps = show.state.trigger_stamps();
let media = crate::media::slide_media(&doc, idx); let media = crate::media::slide_media(&doc, idx);
let hidden: Vec<ShapeId> = media let hidden: Vec<ShapeId> = media
.iter() .iter()
@@ -409,14 +427,15 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect(); let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect();
crate::media::gif_repaint(&ctx, &gifs); crate::media::gif_repaint(&ctx, &gifs);
let gif = crate::media::gif_key(&gifs); let gif = crate::media::gif_key(&gifs);
let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone(), gif }; let done: Vec<(ShapeId, usize)> = plays.iter().map(|p| (p.trigger, p.step)).collect();
let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone(), plays: done, gif };
let tex = if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == key) { let tex = if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == key) {
show.tex.as_ref().map(|(_, t)| t.clone()) show.tex.as_ref().map(|(_, t)| t.clone())
} else { } else {
let tl = show.timeline(&doc).clone(); let tl = show.timeline(&doc).clone();
let hide = hidden.clone(); let hide = hidden.clone();
let f = move |id: ShapeId| { let f = move |id: ShapeId| {
let mut st = state_for(&tl, id, step, t); let mut st = state_for(&tl, id, step, t, &plays, &stamps);
if hide.contains(&id) { if hide.contains(&id) {
st.visible = false; st.visible = false;
} }
@@ -454,7 +473,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
(Some(tr), Some(tl)) => { (Some(tr), Some(tl)) => {
let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0);
let hidden_b = |id: ShapeId| { let hidden_b = |id: ShapeId| {
let st = state_for(&tl, id, 0, 0.0); let st = state_for(&tl, id, 0, 0.0, &[], &[]);
!st.visible || st.opacity <= 0.0 || hidden.contains(&id) !st.visible || st.opacity <= 0.0 || hidden.contains(&id)
}; };
tr.from tr.from
@@ -579,7 +598,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
s.push(pos2((p.x - srect.min.x) / srect.width(), (p.y - srect.min.y) / srect.height())); s.push(pos2((p.x - srect.min.x) / srect.width(), (p.y - srect.min.y) / srect.height()));
} }
ctx.set_cursor_icon(egui::CursorIcon::Crosshair); ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
} else if link.is_some() { } else if pointing {
ctx.set_cursor_icon(egui::CursorIcon::PointingHand); ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
} else if now - show.last_move > 2.5 && !show.reading { } else if now - show.last_move > 2.5 && !show.reading {
ctx.set_cursor_icon(egui::CursorIcon::None); ctx.set_cursor_icon(egui::CursorIcon::None);
@@ -725,9 +744,34 @@ fn media_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation) -> O
}) })
} }
/// The link under the pointer: the topmost shape action or text hyperlink on the current slide /// What a click on the pointer's spot acts on.
/// (a text link wins over its shape's action). #[derive(Clone)]
fn link_hit(ctx: &egui::Context, ui: &Ui, show: &mut Show, doc: &Presentation) -> Option<Hyperlink> { enum Pick {
/// A hyperlink in text.
Text(Hyperlink),
/// A shape with an action or a triggered animation.
Shape,
}
/// The slide's shapes at the pointer, for [`ShowState::click`].
#[derive(Default)]
struct Under {
shapes: Vec<HitShape>,
/// Whether each of `shapes` is currently shown (an animation may have hidden it).
visible: Vec<bool>,
pick: Option<Pick>,
}
impl Under {
fn visible(&self, id: ShapeId) -> bool {
self.shapes.iter().zip(&self.visible).find(|(h, _)| h.id == id).is_none_or(|(_, v)| *v)
}
}
/// What is under the pointer on the shown slide: the slide's shapes with their animation state, the
/// topmost of them that does something when clicked, and a text hyperlink on it. Only shapes that
/// are shown can be clicked, and a shape without an action hides the ones beneath it.
fn shapes_under_pointer(ctx: &egui::Context, ui: &Ui, show: &mut Show, doc: &Presentation, now: f64) -> Option<Under> {
let p = ctx.input(|i| i.pointer.hover_pos())?; let p = ctx.input(|i| i.pointer.hover_pos())?;
let full = ui.max_rect(); let full = ui.max_rect();
let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full };
@@ -735,11 +779,72 @@ fn link_hit(ctx: &egui::Context, ui: &Ui, show: &mut Show, doc: &Presentation) -
let k = (srect.width() / doc.slide_size.width.max(1.0) as f32) as f64; let k = (srect.width() / doc.slide_size.width.max(1.0) as f32) as f64;
let (x, y) = ((p.x - srect.min.x) as f64 / k, (p.y - srect.min.y) as f64 / k); let (x, y) = ((p.x - srect.min.x) as f64 / k, (p.y - srect.min.y) as f64 / k);
let i = show.state.slide; let i = show.state.slide;
if show.links.as_ref().is_none_or(|(s, _)| *s != i) { if show.links.as_ref().is_none_or(|(s, ..)| *s != i) {
show.links = Some((i, deckcraft_render::place_slide(doc, i).links)); let placed = deckcraft_render::place_slide(doc, i);
show.links = Some((i, placed.links, placed.shapes));
}
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
.iter()
.map(|s| {
let a = tl.state_ordered(s.id, None, step, t, &plays, &stamps);
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 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,
Some(h) => {
at = h.parent;
false
}
None => false,
})
});
under.pick = match text {
Some(l) => Some(Pick::Text(l.link.clone())),
None => show.state.target(doc, tl, &under.shapes, &shown).map(|_| Pick::Shape),
};
Some(under)
}
impl Show {
/// Advance the clock of the running triggered animations.
fn tick_triggers(&mut self, doc: &Presentation, now: f64) {
self.timeline(doc);
if let Some((_, tl)) = &self.timeline {
self.state.tick(tl, now);
}
}
/// A click on the slide: the shape under the pointer acts, or the background advances.
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 (_, links) = show.links.as_ref()?;
links.iter().rev().find(|l| l.rect.x0 <= x && x <= l.rect.x1 && l.rect.y0 <= y && y <= l.rect.y1).map(|l| l.link.clone())
} }
/// A slide-space box on screen, the slide being drawn in `srect` at `k` pixels per point. /// A slide-space box on screen, the slide being drawn in `srect` at `k` pixels per point.
@@ -878,7 +983,7 @@ mod tests {
let ctx = egui::Context::default(); let ctx = egui::Context::default();
let red = ctx.load_texture("show", egui::ColorImage::filled([1, 1], Color32::RED), egui::TextureOptions::LINEAR); let red = ctx.load_texture("show", egui::ColorImage::filled([1, 1], Color32::RED), egui::TextureOptions::LINEAR);
let mut show = Show::new(&doc, 0, false, false); let mut show = Show::new(&doc, 0, false, false);
show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0 }, red.clone())); show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0, plays: vec![] }, red.clone()));
assert!(show.apply(ShowAction::Slide(1), &doc, 0.0)); assert!(show.apply(ShowAction::Slide(1), &doc, 0.0));
let tr = show.trans.as_ref().expect("the fade plays"); let tr = show.trans.as_ref().expect("the fade plays");
assert_eq!(tr.old.as_ref().map(TextureHandle::id), Some(red.id())); assert_eq!(tr.old.as_ref().map(TextureHandle::id), Some(red.id()));
+3 -2
View File
@@ -1,6 +1,6 @@
# Transitions and animations: parity with PowerPoint # Transitions and animations: parity with PowerPoint
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** major (transitions measured frame by frame against PowerPoint for Windows' video export and retuned; previous: created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (interactive-deck clicks in the slide show; previous: major (transitions measured frame by frame against PowerPoint for Windows' video export and retuned; previous: created)) · **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 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) notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md)
@@ -67,7 +67,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
| Effect Options (direction, sequence by paragraph) | yes | yes | | Effect Options (direction, sequence by paragraph) | yes | yes |
| Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; rewind lost on PPTX save (#77) | | Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; rewind lost on PPTX save (#77) |
| By-paragraph text builds, sub-bullets follow parents | yes | yes | | By-paragraph text builds, sub-bullets follow parents | yes | yes |
| Triggers (on click of a shape) | yes | yes | | Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step |
| Triggers on a media bookmark | yes | no | | Triggers on a media bookmark | yes | no |
| Animation Painter | yes | yes (#54) | | Animation Painter | yes | yes (#54) |
| Animation Pane: list, reorder, remove | yes | yes | | Animation Pane: list, reorder, remove | yes | yes |
@@ -94,4 +94,5 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-13 | major | Transitions measured frame by frame against PowerPoint's video export and retuned (mean diff 35.7 → 15.0); PowerPoint's XML defaults and option meanings; new options | | 2026-10-13 | major | Transitions measured frame by frame against PowerPoint's video export and retuned (mean diff 35.7 → 15.0); PowerPoint's XML defaults and option meanings; new options |
| 2026-10-13 | minor | Triggers now play in the slide show (they were read and written but never started), effects of different triggers apply in the order they were clicked |
| 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates | | 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates |
+16 -1
View File
@@ -1,6 +1,6 @@
# Where DeckCraft falls short of PowerPoint # Where DeckCraft falls short of PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (interactive-deck clicks in the slide show; previous: minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues)) · **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 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 PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
@@ -159,6 +159,20 @@ Detail in [file-format-parity.md](file-format-parity.md).
### Animation Pane, effect count and preview ### Animation Pane, effect count and preview
Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a **beta blocker**. Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a **beta blocker**.
### Interactive decks: media-action links, audio-triggered effects and click boxes
- **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
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; the PPTX writer turns every "on click" after the
first effect of a trigger into "after previous", so a trigger with several click steps saved from
DeckCraft plays all of them at once.
- **Impact:** games built around sound effects on buttons, or around a button that counts up with
repeated clicks, lose that part when they are saved from DeckCraft or played in it.
- **Estimate:** 4–8 h.
### Notes and Handout masters ### Notes and Handout masters
- **Evidence:** checklist row missing; PPTX writes a notes master but there's no view to edit it, - **Evidence:** checklist row missing; PPTX writes a notes master but there's no view to edit it,
and no handout master at all. and no handout master at all.
@@ -257,6 +271,7 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 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 | 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-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 | 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 | 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 | | 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |
+5 -1
View File
@@ -1,6 +1,6 @@
# Slide show and presenter view: parity with PowerPoint # Slide show and presenter view: parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; measured from `crates/ui-egui/src/show.rs` and `crates/anim/src/show.rs` against PowerPoint 16.113's Slide Show tab and menu) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (interactive-deck clicks in the slide show; previous: major (created; measured from `crates/ui-egui/src/show.rs` and `crates/anim/src/show.rs` against PowerPoint 16.113's Slide Show tab and menu)) · **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, 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 rehearsal and recording. Transition and animation playback are in
@@ -20,6 +20,9 @@ ready** (estimated), **10–16 h** to parity.
| Step clock: a click after a long pause plays its step (#11) | yes | yes | | | Step clock: a click after a long pause plays its step (#11) | yes | yes | |
| Hidden slides skipped | 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`) | | 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 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 | |
| Media playback with controls, play across slides, full-screen video | yes | yes | WMA/WMV not decodable | | Media playback with controls, play across slides, full-screen video | yes | yes | WMA/WMV not decodable |
| Animated GIFs play; pause from their corner button | yes | yes (#39) | | | Animated GIFs play; pause from their corner button | yes | yes (#39) | |
| See All Slides grid during the show | yes | no | | | See All Slides grid during the show | yes | no | |
@@ -73,4 +76,5 @@ Reading View in a window with navigation: present (checklist row done).
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 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-10 | major | Created: show, presenter view, rehearse and record, compared with PowerPoint 16.113's Slide Show tab and menu | | 2026-10-10 | major | Created: show, presenter view, rehearse and record, compared with PowerPoint 16.113's Slide Show tab and menu |