mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-28 12:36:56 -06:00
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>
113 lines
4.2 KiB
Rust
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) }
|
|
}
|