Files
deckcraft/crates/anim/src/lib.rs
T
7f1e152870 Links: Insert Hyperlink with a bare caret inserts the address; the show follows links (#31) (#45)
Three causes, each making links look like they "don't save":

- With the caret at a word boundary (end of text, empty paragraph),
  insert.hyperlink formatted an empty range and changed nothing. It now
  inserts the address (or the slide's title, or `text`) as linked text, as
  PowerPoint does. Typing right after a link no longer extends it.
- The slide show ignored hyperlinks and shape actions. A click on one now
  follows it (ShowState::follow): slides, next/previous/first/last, Last
  Slide Viewed, End Show, and URLs; the pointer turns into a hand over links.
- The desktop app was built without eframe's `links` feature, so
  ctx.open_url did nothing natively (also the ribbon's app page link).
  Enabled as in DesignCraft, EffectCraft and PrintCraft.

Saving was fine: links round-trip through .deckcraft and .pptx.

generated with Claude Code

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:23:01 -04:00

113 lines
4.2 KiB
Rust

//! Slide show timing for DeckCraft.
//!
//! - [`Timeline`]: a slide's animation list grouped into click steps, with per-shape (and
//! per-paragraph) [`AnimState`] at any point of the show.
//! - [`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_xfrm`]).
//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings).
//!
//! Pure logic: no rendering, no UI toolkit. Every function accepts hostile numbers (NaN, huge or
//! negative times) and never panics.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod easing;
mod effects;
mod morph;
mod path;
mod show;
mod timeline;
mod transition;
#[cfg(test)]
mod tests;
pub use deckcraft_model::Rgba;
use serde::{Deserialize, Serialize};
pub use easing::ease;
pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm};
pub use path::{MotionPath, default_path};
pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order};
pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay};
pub use transition::{Layer, Source, transition_layers};
/// Animation state of one shape (or one paragraph of a shape's text) at one moment of the show.
///
/// Same meaning as the renderer's `ShapeState`: translation in points, scale and rotation (degrees)
/// around the shape's centre, a reveal clip in shape-local unit space `[l, t, r, b]`, and a fill
/// colour override.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct AnimState {
pub visible: bool,
pub opacity: f64,
pub offset_x: f64,
pub offset_y: f64,
pub scale_x: f64,
pub scale_y: f64,
pub rotate: f64,
pub clip: Option<[f64; 4]>,
pub tint: Option<Rgba>,
}
impl Default for AnimState {
fn default() -> Self {
AnimState { visible: true, opacity: 1.0, offset_x: 0.0, offset_y: 0.0, scale_x: 1.0, scale_y: 1.0, rotate: 0.0, clip: None, tint: None }
}
}
impl AnimState {
/// A hidden state.
pub fn hidden() -> Self {
AnimState { visible: false, ..Default::default() }
}
/// Is this the identity (nothing to apply)?
pub fn is_identity(&self) -> bool {
*self == AnimState::default()
}
/// Apply `d` on top of `self`: opacities and scales multiply, offsets and rotations add, clips
/// intersect, a tint replaces.
pub fn compose(&self, d: &AnimState) -> AnimState {
let clip = match (self.clip, d.clip) {
(Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),
(a, None) => a,
(None, b) => b,
};
AnimState {
visible: self.visible && d.visible,
opacity: self.opacity * d.opacity,
offset_x: self.offset_x + d.offset_x,
offset_y: self.offset_y + d.offset_y,
scale_x: self.scale_x * d.scale_x,
scale_y: self.scale_y * d.scale_y,
rotate: self.rotate + d.rotate,
clip,
tint: d.tint.or(self.tint),
}
}
/// Replace any non-finite number with its neutral value and clamp opacity and clip.
pub fn sanitized(mut self) -> AnimState {
let f = |v: f64, d: f64| if v.is_finite() { v } else { d };
self.opacity = f(self.opacity, 1.0).clamp(0.0, 1.0);
self.offset_x = f(self.offset_x, 0.0);
self.offset_y = f(self.offset_y, 0.0);
self.scale_x = f(self.scale_x, 1.0);
self.scale_y = f(self.scale_y, 1.0);
self.rotate = f(self.rotate, 0.0);
self.clip = self.clip.map(|c| {
let l = f(c[0], 0.0).clamp(0.0, 1.0);
let t = f(c[1], 0.0).clamp(0.0, 1.0);
let r = f(c[2], 1.0).clamp(0.0, 1.0);
let b = f(c[3], 1.0).clamp(0.0, 1.0);
[l, t, r.max(l), b.max(t)]
});
self
}
}
/// Clamp a hostile number into `[lo, hi]`, mapping NaN to `nan`.
pub(crate) fn clampf(v: f64, lo: f64, hi: f64, nan: f64) -> f64 {
if v.is_nan() { nan } else { v.clamp(lo, hi) }
}