use std::sync::OnceLock;
use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::RenderCtx;
use crate::motion::{CubicBezier, normalised_speed};
use crate::ui::{Mode, UiTheme, blur, text};
const TRACKING_EM: f32 = -0.02;
const WORD_GAP_EM: f32 = 0.25;
const SHUTTER: f32 = 1.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TextSwapTransition {
#[default]
FlyThrough,
FadeThrough,
Crossfade,
SharedAxisY,
SharedAxisZ,
Cut,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TextSwapUnit {
#[default]
Block,
Word,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Ease {
Curve(CubicBezier),
Step1,
}
impl Ease {
#[must_use]
pub fn ease(self, t: f32) -> f32 {
match self {
Self::Curve(curve) => curve.ease(t),
Self::Step1 => {
if t >= 1.0 {
1.0
} else {
0.0
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TransitionDefaults {
pub unit: TextSwapUnit,
pub exit_duration: f32,
pub enter_duration: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TransitionMotion {
pub exit_y: f32,
pub exit_scale: f32,
pub exit_blur: f32,
pub enter_y: f32,
pub enter_scale: f32,
pub enter_blur: f32,
pub exit_perspective: bool,
pub exit_fade_start: f32,
pub exit_trail: usize,
}
impl TransitionMotion {
const fn still() -> Self {
Self {
exit_y: 0.0,
exit_scale: 1.0,
exit_blur: 0.0,
enter_y: 0.0,
enter_scale: 1.0,
enter_blur: 0.0,
exit_perspective: false,
exit_fade_start: 0.0,
exit_trail: 1,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TransitionEasing {
pub exit: Ease,
pub enter: Ease,
}
impl TextSwapTransition {
#[must_use]
pub fn defaults(self) -> TransitionDefaults {
match self {
Self::FlyThrough => TransitionDefaults {
unit: TextSwapUnit::Block,
exit_duration: 20.0,
enter_duration: 16.0,
},
Self::FadeThrough => TransitionDefaults {
unit: TextSwapUnit::Block,
exit_duration: 8.0,
enter_duration: 13.0,
},
Self::Crossfade => TransitionDefaults {
unit: TextSwapUnit::Word,
exit_duration: 15.0,
enter_duration: 21.0,
},
Self::SharedAxisY => TransitionDefaults {
unit: TextSwapUnit::Block,
exit_duration: 10.0,
enter_duration: 14.0,
},
Self::SharedAxisZ => TransitionDefaults {
unit: TextSwapUnit::Block,
exit_duration: 11.0,
enter_duration: 16.0,
},
Self::Cut => TransitionDefaults {
unit: TextSwapUnit::Word,
exit_duration: 8.0,
enter_duration: 8.0,
},
}
}
#[must_use]
pub fn motion(self) -> TransitionMotion {
match self {
Self::FlyThrough => TransitionMotion {
exit_y: 0.0,
exit_scale: 12.0,
exit_blur: 0.5,
exit_perspective: true,
exit_fade_start: 0.72,
exit_trail: 18,
enter_y: 0.0,
enter_scale: 0.82,
enter_blur: 9.0,
},
Self::FadeThrough => TransitionMotion {
exit_y: -4.0,
exit_scale: 1.0,
exit_blur: 0.0,
enter_y: 6.0,
enter_scale: 0.99,
enter_blur: 2.0,
..TransitionMotion::still()
},
Self::Crossfade => TransitionMotion {
exit_y: -6.0,
exit_scale: 1.0,
exit_blur: 0.0,
enter_y: 8.0,
enter_scale: 1.0,
enter_blur: 0.0,
..TransitionMotion::still()
},
Self::SharedAxisY => TransitionMotion {
exit_y: -24.0,
exit_scale: 1.0,
exit_blur: 0.0,
enter_y: 24.0,
enter_scale: 1.0,
enter_blur: 0.0,
..TransitionMotion::still()
},
Self::SharedAxisZ => TransitionMotion {
exit_y: 0.0,
exit_scale: 1.06,
exit_blur: 1.0,
enter_y: 0.0,
enter_scale: 0.9,
enter_blur: 2.0,
..TransitionMotion::still()
},
Self::Cut => TransitionMotion::still(),
}
}
#[must_use]
pub fn easing(self) -> TransitionEasing {
match self {
Self::FlyThrough => TransitionEasing {
exit: Ease::Curve(CubicBezier::new(1.0, 0.65, 0.85, 1.0)),
enter: Ease::Curve(CubicBezier::new(0.2, 0.6, 0.35, 1.0)),
},
Self::FadeThrough | Self::SharedAxisY | Self::SharedAxisZ => TransitionEasing {
exit: Ease::Curve(CubicBezier::new(0.4, 0.0, 1.0, 1.0)),
enter: Ease::Curve(CubicBezier::new(0.2, 0.0, 0.0, 1.0)),
},
Self::Crossfade => TransitionEasing {
exit: Ease::Curve(CubicBezier::new(0.7, 0.0, 0.84, 0.0)),
enter: Ease::Curve(CubicBezier::new(0.16, 1.0, 0.3, 1.0)),
},
Self::Cut => TransitionEasing {
exit: Ease::Step1,
enter: Ease::Step1,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SegmentChannels {
pub y: f32,
pub scale: f32,
pub opacity: f32,
pub blur: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextSwapConfig {
pub from_text: String,
pub to_text: String,
pub transition: TextSwapTransition,
pub unit: TextSwapUnit,
pub exit_duration: f32,
pub enter_duration: f32,
pub exit_stagger: f32,
pub enter_stagger: f32,
pub overlap: f32,
pub micro_delay: f32,
pub font_size: f32,
pub color: Option<String>,
pub font_weight: u16,
pub tracking_em: f32,
pub speed: f32,
pub mode: Mode,
pub theme: Option<UiTheme>,
pub font_family: Option<String>,
pub duration: usize,
pub simulate_blur: bool,
}
impl Default for TextSwapConfig {
fn default() -> Self {
Self {
from_text: "Manual invoicing".to_owned(),
to_text: "Automated with Acme".to_owned(),
transition: TextSwapTransition::FlyThrough,
unit: TextSwapUnit::Block,
exit_duration: 20.0,
enter_duration: 16.0,
exit_stagger: 1.0,
enter_stagger: 2.0,
overlap: 1.0,
micro_delay: 2.0,
font_size: 72.0,
color: Some("#101828".to_owned()),
font_weight: 600,
tracking_em: TRACKING_EM,
speed: 1.0,
mode: Mode::Light,
theme: None,
font_family: None,
duration: 90,
simulate_blur: true,
}
}
}
impl TextSwapConfig {
#[must_use]
pub fn for_transition(transition: TextSwapTransition) -> Self {
let defaults = transition.defaults();
Self {
transition,
unit: defaults.unit,
exit_duration: defaults.exit_duration,
enter_duration: defaults.enter_duration,
..Self::default()
}
}
#[must_use]
pub fn exit_total(&self) -> f32 {
exit_total(
self.exit_duration,
split_segments(&self.from_text, self.unit).len(),
self.exit_stagger,
)
}
#[must_use]
pub fn enter_start(&self) -> f32 {
enter_start(
self.exit_duration,
split_segments(&self.from_text, self.unit).len(),
self.exit_stagger,
self.overlap,
self.micro_delay,
)
}
#[must_use]
pub fn settle_frame(&self) -> f32 {
settle_frame(
self.enter_start(),
split_segments(&self.to_text, self.unit).len(),
self.enter_stagger,
self.enter_duration,
)
}
}
#[must_use]
pub fn split_segments(text: &str, unit: TextSwapUnit) -> Vec<String> {
match unit {
TextSwapUnit::Word => text.split(' ').map(str::to_owned).collect(),
TextSwapUnit::Block => vec![text.to_owned()],
}
}
#[must_use]
pub fn exit_total(exit_duration: f32, segment_count: usize, exit_stagger: f32) -> f32 {
exit_duration + segment_count.saturating_sub(1) as f32 * exit_stagger
}
#[must_use]
pub fn enter_start(
exit_duration: f32,
segment_count: usize,
exit_stagger: f32,
overlap: f32,
micro_delay: f32,
) -> f32 {
(exit_total(exit_duration, segment_count, exit_stagger) - overlap + micro_delay).max(0.0)
}
#[must_use]
pub fn settle_frame(
enter_start: f32,
segment_count: usize,
enter_stagger: f32,
enter_duration: f32,
) -> f32 {
enter_start + segment_count.saturating_sub(1) as f32 * enter_stagger + enter_duration
}
#[must_use]
pub fn perspective_scale(travel: f32, max_scale: f32) -> f32 {
if max_scale <= 1.0 {
return 1.0;
}
let p = 1.0 - 1.0 / max_scale;
1.0 / (1.0 - p * travel.clamp(0.0, 0.9999))
}
#[derive(Debug, Clone, PartialEq)]
pub struct LineSlots {
pub widths: Vec<f32>,
pub gap: f32,
pub trailing: f32,
pub centers: Vec<f32>,
}
impl LineSlots {
#[must_use]
pub fn new(widths: Vec<f32>, gap: f32, trailing: f32) -> Self {
let count = widths.len();
let total = widths.iter().sum::<f32>() + gap * count.saturating_sub(1) as f32 + trailing;
let mut centers = Vec::with_capacity(count);
let mut cursor = -total / 2.0;
for width in &widths {
centers.push(cursor + width / 2.0);
cursor += width + gap;
}
Self {
widths,
gap,
trailing,
centers,
}
}
}
fn eased_progress(frame: f32, from: f32, to: f32, empty: f32, ease: Ease) -> f32 {
if (to - from).abs() < f32::EPSILON {
return empty;
}
ease.ease(((frame - from) / (to - from)).clamp(0.0, 1.0))
}
#[must_use]
pub fn exit_channels(
local: f32,
duration: f32,
motion: &TransitionMotion,
ease: Ease,
) -> SegmentChannels {
let travel = eased_progress(local, 0.0, duration, 0.0, ease);
let fade_from = motion.exit_fade_start * duration;
let opacity = 1.0 - eased_progress(local, fade_from, duration, 0.0, ease);
let scale = if motion.exit_perspective {
perspective_scale(travel, motion.exit_scale)
} else {
1.0 + (motion.exit_scale - 1.0) * travel
};
SegmentChannels {
y: motion.exit_y * travel,
scale,
opacity,
blur: motion.exit_blur * travel,
}
}
#[must_use]
pub fn enter_channels(
local: f32,
duration: f32,
motion: &TransitionMotion,
ease: Ease,
) -> SegmentChannels {
let progress = eased_progress(local, 0.0, duration, 1.0, ease);
SegmentChannels {
y: motion.enter_y * (1.0 - progress),
scale: motion.enter_scale + (1.0 - motion.enter_scale) * progress,
opacity: progress,
blur: motion.enter_blur * (1.0 - progress),
}
}
#[must_use]
pub fn shutter_samples(
frame: f32,
duration: f32,
motion: &TransitionMotion,
ease: Ease,
) -> Vec<(f32, f32)> {
let trail = motion.exit_trail.max(1);
let share = 1.0 / trail as f32;
let mut groups: Vec<(f32, f32)> = Vec::with_capacity(trail);
for sample in 0..trail {
let sample_frame = frame - (sample as f32 / trail as f32) * SHUTTER;
let channels = exit_channels(sample_frame, duration, motion, ease);
match groups.last_mut() {
Some((previous, weight))
if same_channels(&exit_channels(*previous, duration, motion, ease), &channels) =>
{
*weight += share;
}
_ => groups.push((sample_frame, share)),
}
}
groups
}
fn same_channels(a: &SegmentChannels, b: &SegmentChannels) -> bool {
const EPSILON: f32 = 1e-6;
(a.y - b.y).abs() <= EPSILON
&& (a.scale - b.scale).abs() <= EPSILON
&& (a.opacity - b.opacity).abs() <= EPSILON
&& (a.blur - b.blur).abs() <= EPSILON
}
#[derive(Debug, Clone, PartialEq)]
struct Line {
segments: Vec<String>,
slots: LineSlots,
}
#[derive(Debug, Clone, PartialEq)]
struct Measured {
from: Line,
to: Line,
}
struct SegmentStyle<'a> {
family: &'a str,
size: f32,
weight: u16,
tracking_px: f32,
fill: &'a str,
baseline: f32,
center_y: f32,
unit: f32,
simulate_blur: bool,
}
fn draw_segment(
nodes: &mut Vec<Svgr<'static>>,
ctx: &RenderCtx<'_>,
style: &SegmentStyle<'_>,
body: &str,
x: f32,
channels: SegmentChannels,
) {
if body.is_empty() || channels.opacity <= 0.0 {
return;
}
let mut parts: Vec<Svgr<'static>> = Vec::new();
if style.simulate_blur && blur::is_visible(channels.blur) {
let alpha = blur::ghost_alpha(channels.blur, style.unit) * channels.opacity;
for (dx, dy) in blur::ghost_offsets(channels.blur, style.unit) {
parts.push(text::text_node(
ctx,
body,
x + dx,
style.baseline + dy,
style.family.to_owned(),
style.size,
style.weight,
style.tracking_px,
style.fill.to_owned(),
alpha,
));
}
}
parts.push(text::text_node(
ctx,
body,
x,
style.baseline,
style.family.to_owned(),
style.size,
style.weight,
style.tracking_px,
style.fill.to_owned(),
channels.opacity,
));
let group = Svgr::from(parts);
let shift = channels.y * style.unit;
if (channels.scale - 1.0).abs() < 1e-4 && shift.abs() < 1e-4 {
nodes.push(group);
return;
}
let (nx, ny) = (-x, -style.center_y);
let ty = style.center_y + shift;
let scale = channels.scale;
let transform = format!("translate({x} {ty}) scale({scale}) translate({nx} {ny})");
nodes.push(fframes::svgr!(<g transform={transform}>{group}</g>));
}
#[derive(Debug, Clone, Default)]
pub struct TextSwap {
pub config: TextSwapConfig,
measured: OnceLock<Measured>,
}
impl TextSwap {
#[must_use]
pub fn new(config: TextSwapConfig) -> Self {
Self {
config,
measured: OnceLock::new(),
}
}
#[must_use]
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "cinematic-text-swap",
name: "Cinematic Text Swap",
summary: "One line exits and the next enters, on a shared schedule: rush, crossfade, share an axis, or cut",
feature: "cinematic-text-swap",
}
}
fn measured(&self, ctx: &RenderCtx<'_>, family: &str) -> &Measured {
self.measured.get_or_init(|| {
let config = &self.config;
let size = config.font_size * text::unit(ctx);
let tracking_px = text::tracking_px_from_em(config.tracking_em, size);
let gap = WORD_GAP_EM * size + tracking_px;
let trailing = if config.unit == TextSwapUnit::Word {
gap
} else {
0.0
};
let line = |source: &str| {
let segments = split_segments(source, config.unit);
let widths = segments
.iter()
.map(|segment| {
text::tracked_width_at(
ctx,
family,
config.font_weight,
size,
segment,
tracking_px,
)
})
.collect();
Line {
segments,
slots: LineSlots::new(widths, gap, trailing),
}
};
Measured {
from: line(&config.from_text),
to: line(&config.to_text),
}
})
}
}
impl Component for TextSwap {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
self.config.duration
}
fn background(&self) -> Option<&'static str> {
Some("#ffffff")
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
let unit = text::unit(ctx);
let theme = config
.theme
.clone()
.unwrap_or_else(|| UiTheme::for_mode(config.mode));
let fill = config
.color
.clone()
.unwrap_or_else(|| theme.foreground.clone());
let family =
text::resolve_face(config.font_family.as_ref(), theme.font_family.as_ref(), ctx);
let size = config.font_size * unit;
let tracking_px = text::tracking_px_from_em(config.tracking_em, size);
let frame = ctx.frame() * normalised_speed(config.speed);
let center_x = ctx.width() / 2.0;
let center_y = ctx.height() / 2.0;
let baseline = text::baseline_for_centred_box(ctx, &family, size, center_y);
let measured = self.measured(ctx, &family);
let motion = config.transition.motion();
let easing = config.transition.easing();
let enter_start = config.enter_start();
let style = SegmentStyle {
family: &family,
size,
weight: config.font_weight,
tracking_px,
fill: &fill,
baseline,
center_y,
unit,
simulate_blur: config.simulate_blur,
};
let mut nodes: Vec<Svgr<'static>> = Vec::new();
for (sample_frame, share) in
shutter_samples(frame, config.exit_duration, &motion, easing.exit)
{
for (index, segment) in measured.from.segments.iter().enumerate() {
let local = sample_frame - index as f32 * config.exit_stagger;
let channels = exit_channels(local, config.exit_duration, &motion, easing.exit);
let channels = SegmentChannels {
opacity: (share * channels.opacity).min(1.0),
..channels
};
let x = center_x + measured.from.slots.centers[index] * unit;
draw_segment(&mut nodes, ctx, &style, segment, x, channels);
}
}
for (index, segment) in measured.to.segments.iter().enumerate() {
let local = frame - enter_start - index as f32 * config.enter_stagger;
let channels = enter_channels(local, config.enter_duration, &motion, easing.enter);
let x = center_x + measured.to.slots.centers[index] * unit;
draw_segment(&mut nodes, ctx, &style, segment, x, channels);
}
let nodes = Svgr::from(nodes);
let width = ctx.width();
let height = ctx.height();
fframes::svgr!(
<g>
<defs>
<clipPath id="cinematic-text-swap-stage">
<rect x="0" y="0" width={width} height={height} />
</clipPath>
</defs>
<g clip-path="url(#cinematic-text-swap-stage)">{nodes}</g>
</g>
)
}
}
#[cfg(test)]
mod tests {
use super::*;
const PRESETS: [TextSwapTransition; 6] = [
TextSwapTransition::FlyThrough,
TextSwapTransition::FadeThrough,
TextSwapTransition::Crossfade,
TextSwapTransition::SharedAxisY,
TextSwapTransition::SharedAxisZ,
TextSwapTransition::Cut,
];
#[test]
fn duration_defaults_to_the_source_composition_length() {
let component = TextSwap::default();
assert_eq!(component.duration(), 90);
assert_eq!(component.meta().id, "cinematic-text-swap");
assert_eq!(component.meta().feature, "cinematic-text-swap");
assert_eq!(component.background(), Some("#ffffff"));
}
#[test]
fn the_source_defaults_are_reproduced_exactly() {
let config = TextSwapConfig::default();
assert_eq!(config.from_text, "Manual invoicing");
assert_eq!(config.to_text, "Automated with Acme");
assert_eq!(config.transition, TextSwapTransition::FlyThrough);
assert_eq!(config.unit, TextSwapUnit::Block);
assert!((config.exit_duration - 20.0).abs() < 1e-6);
assert!((config.enter_duration - 16.0).abs() < 1e-6);
assert!((config.exit_stagger - 1.0).abs() < 1e-6);
assert!((config.enter_stagger - 2.0).abs() < 1e-6);
assert!((config.overlap - 1.0).abs() < 1e-6);
assert!((config.micro_delay - 2.0).abs() < 1e-6);
assert!((config.font_size - 72.0).abs() < 1e-6);
assert_eq!(config.color.as_deref(), Some("#101828"));
assert_eq!(config.font_weight, 600);
assert!((config.tracking_em + 0.02).abs() < 1e-6);
assert!((config.speed - 1.0).abs() < 1e-6);
assert_eq!(config.mode, Mode::Light);
assert_eq!(config.theme, None);
assert_eq!(config.font_family, None);
assert_eq!(config.duration, 90);
assert!(config.simulate_blur);
}
#[test]
fn the_default_copy_settles_on_the_measured_frame() {
let config = TextSwapConfig::default();
assert_eq!(config.from_text.split(' ').count(), 2, "two words leave");
assert_eq!(config.to_text.split(' ').count(), 3, "three arrive");
assert!((config.exit_total() - 20.0).abs() < 1e-6);
assert!((config.enter_start() - 21.0).abs() < 1e-6);
assert!((config.settle_frame() - 37.0).abs() < 1e-6);
}
#[test]
fn the_block_unit_schedule_does_not_depend_on_the_word_count() {
let config = TextSwapConfig::default();
let settle = config.settle_frame();
for (from, to) in [
("one", "two"),
("a much longer line of words", "another line entirely"),
("Manual invoicing and then some", "Automated with Acme"),
] {
let wider = TextSwapConfig {
from_text: from.to_owned(),
to_text: to.to_owned(),
..TextSwapConfig::default()
};
assert!(
(wider.settle_frame() - settle).abs() < 1e-6,
"{from:?} -> {to:?} settled on {} instead of {settle}",
wider.settle_frame()
);
}
}
#[test]
fn each_preset_carries_the_source_timing_and_unit() {
let expected = [
(TextSwapUnit::Block, 20.0, 16.0),
(TextSwapUnit::Block, 8.0, 13.0),
(TextSwapUnit::Word, 15.0, 21.0),
(TextSwapUnit::Block, 10.0, 14.0),
(TextSwapUnit::Block, 11.0, 16.0),
(TextSwapUnit::Word, 8.0, 8.0),
];
for (preset, (unit, exit, enter)) in PRESETS.iter().zip(expected) {
let defaults = preset.defaults();
assert_eq!(defaults.unit, unit, "{preset:?} unit");
assert!(
(defaults.exit_duration - exit).abs() < 1e-6,
"{preset:?} exit"
);
assert!(
(defaults.enter_duration - enter).abs() < 1e-6,
"{preset:?} enter"
);
let config = TextSwapConfig::for_transition(*preset);
assert_eq!(config.unit, unit);
assert!((config.exit_duration - exit).abs() < 1e-6);
assert!((config.enter_duration - enter).abs() < 1e-6);
assert_eq!(config.from_text, TextSwapConfig::default().from_text);
assert!((config.exit_stagger - 1.0).abs() < 1e-6);
}
}
#[test]
fn each_preset_carries_the_source_motion_targets() {
let expected = [
(0.0, 12.0, 0.5, 0.0, 0.82, 9.0),
(-4.0, 1.0, 0.0, 6.0, 0.99, 2.0),
(-6.0, 1.0, 0.0, 8.0, 1.0, 0.0),
(-24.0, 1.0, 0.0, 24.0, 1.0, 0.0),
(0.0, 1.06, 1.0, 0.0, 0.9, 2.0),
(0.0, 1.0, 0.0, 0.0, 1.0, 0.0),
];
for (preset, (exit_y, exit_scale, exit_blur, enter_y, enter_scale, enter_blur)) in
PRESETS.iter().zip(expected)
{
let motion = preset.motion();
assert!((motion.exit_y - exit_y).abs() < 1e-6, "{preset:?} exit_y");
assert!(
(motion.exit_scale - exit_scale).abs() < 1e-6,
"{preset:?} exit_scale"
);
assert!(
(motion.exit_blur - exit_blur).abs() < 1e-6,
"{preset:?} exit_blur"
);
assert!(
(motion.enter_y - enter_y).abs() < 1e-6,
"{preset:?} enter_y"
);
assert!(
(motion.enter_scale - enter_scale).abs() < 1e-6,
"{preset:?} enter_scale"
);
assert!(
(motion.enter_blur - enter_blur).abs() < 1e-6,
"{preset:?} enter_blur"
);
}
}
#[test]
fn only_the_fly_through_rushes_and_only_it_uses_the_shutter() {
for preset in PRESETS {
let motion = preset.motion();
let rushes = preset == TextSwapTransition::FlyThrough;
assert_eq!(motion.exit_perspective, rushes, "{preset:?} perspective");
assert_eq!(motion.exit_trail > 1, rushes, "{preset:?} trail");
assert_eq!(
motion.exit_fade_start > 0.0,
rushes,
"{preset:?} fade start"
);
if rushes {
assert!(motion.exit_fade_start > 0.5);
assert!(motion.exit_scale > 5.0);
assert_eq!(motion.exit_trail, 18);
assert!((motion.exit_fade_start - 0.72).abs() < 1e-6);
} else {
assert_eq!(motion.exit_trail, 1);
assert!((motion.exit_fade_start).abs() < 1e-6);
}
}
}
#[test]
fn each_preset_carries_the_source_easings() {
let shared = TransitionEasing {
exit: Ease::Curve(CubicBezier::new(0.4, 0.0, 1.0, 1.0)),
enter: Ease::Curve(CubicBezier::new(0.2, 0.0, 0.0, 1.0)),
};
let expected = [
TransitionEasing {
exit: Ease::Curve(CubicBezier::new(1.0, 0.65, 0.85, 1.0)),
enter: Ease::Curve(CubicBezier::new(0.2, 0.6, 0.35, 1.0)),
},
shared,
TransitionEasing {
exit: Ease::Curve(CubicBezier::new(0.7, 0.0, 0.84, 0.0)),
enter: Ease::Curve(CubicBezier::new(0.16, 1.0, 0.3, 1.0)),
},
shared,
shared,
TransitionEasing {
exit: Ease::Step1,
enter: Ease::Step1,
},
];
for (preset, easing) in PRESETS.iter().zip(expected) {
assert_eq!(preset.easing(), easing, "{preset:?}");
}
}
#[test]
fn split_segments_follows_the_source_unit() {
assert_eq!(
split_segments("Manual invoicing today", TextSwapUnit::Word),
vec!["Manual", "invoicing", "today"]
);
assert_eq!(
split_segments("Manual invoicing today", TextSwapUnit::Block),
vec!["Manual invoicing today"]
);
assert_eq!(split_segments("Acme", TextSwapUnit::Word), vec!["Acme"]);
assert_eq!(split_segments("Acme", TextSwapUnit::Block), vec!["Acme"]);
assert_eq!(
split_segments("Manual invoicing", TextSwapUnit::Word),
vec!["Manual", "", "invoicing"]
);
}
#[test]
fn get_enter_start_follows_the_source_schedule() {
assert!(
(enter_start(8.0, 1, 1.0, 1.0, 2.0) - 9.0).abs() < 1e-6,
"a single block: exitTotal 8 - 1 + 2"
);
assert!(
(enter_start(15.0, 3, 1.0, 5.0, 2.0) - 14.0).abs() < 1e-6,
"exitTotal = 15 + 2 * 1 = 17, then 17 - 5 + 2"
);
assert!(
(enter_start(2.0, 1, 0.0, 10.0, 0.0) - 0.0).abs() < 1e-6,
"the enter is never scheduled before frame 0"
);
assert!(
(enter_start(8.0, 0, 4.0, 0.0, 0.0) - 8.0).abs() < 1e-6,
"a zero segment count adds no stagger tail and does not underflow"
);
}
#[test]
fn exit_total_extends_by_the_stagger_tail() {
assert!((exit_total(8.0, 1, 1.0) - 8.0).abs() < 1e-6);
assert!((exit_total(15.0, 3, 1.0) - 17.0).abs() < 1e-6);
assert!((exit_total(15.0, 2, 2.0) - 17.0).abs() < 1e-6);
assert!((settle_frame(21.0, 1, 2.0, 16.0) - 37.0).abs() < 1e-6);
assert!((settle_frame(21.0, 3, 2.0, 16.0) - 41.0).abs() < 1e-6);
}
#[test]
fn the_per_preset_schedules_are_the_sources() {
let expected = [
(20.0, 21.0, 37.0),
(8.0, 9.0, 22.0),
(16.0, 17.0, 42.0),
(10.0, 11.0, 25.0),
(11.0, 12.0, 28.0),
(9.0, 10.0, 22.0),
];
for (preset, (exit, start, settle)) in PRESETS.iter().zip(expected) {
let config = TextSwapConfig::for_transition(*preset);
assert!(
(config.exit_total() - exit).abs() < 1e-6,
"{preset:?} exit total {}",
config.exit_total()
);
assert!(
(config.enter_start() - start).abs() < 1e-6,
"{preset:?} enter start {}",
config.enter_start()
);
assert!(
(config.settle_frame() - settle).abs() < 1e-6,
"{preset:?} settle {}",
config.settle_frame()
);
}
}
#[test]
fn the_word_unit_extends_the_exit_by_its_stagger_tail() {
let config = TextSwapConfig::for_transition(TextSwapTransition::Crossfade);
assert_eq!(config.unit, TextSwapUnit::Word);
assert_eq!(split_segments(&config.from_text, config.unit).len(), 2);
assert!((config.exit_duration - 15.0).abs() < 1e-6);
assert!((config.exit_total() - 16.0).abs() < 1e-6);
}
#[test]
fn perspective_scale_is_one_at_rest_and_max_scale_at_the_eye() {
assert!((perspective_scale(0.0, 12.0) - 1.0).abs() < 1e-6);
assert!((perspective_scale(1.0, 12.0) - 12.0).abs() < 0.05);
assert!((perspective_scale(0.5, 1.0) - 1.0).abs() < 1e-6);
assert!((perspective_scale(0.5, 0.0) - 1.0).abs() < 1e-6);
for travel in [-1.0, 0.0, 0.5, 1.0, 2.0] {
assert!(
perspective_scale(travel, 12.0).is_finite(),
"travel {travel}"
);
}
assert!(perspective_scale(2.0, 12.0) <= 12.0);
assert!(perspective_scale(2.0, 12.0) > 11.0);
}
#[test]
fn perspective_scale_creeps_early_and_blows_up_late() {
let half = perspective_scale(0.5, 12.0);
let linear_half = 1.0 + (12.0 - 1.0) * 0.5;
assert!(half < linear_half / 2.0, "half travel gives {half}");
assert!(perspective_scale(0.9, 12.0) > perspective_scale(0.8, 12.0) * 1.5);
}
#[test]
fn perspective_scale_reproduces_the_references_blowup() {
let scale = perspective_scale(0.985, 12.0);
assert!(
(10.0..10.6).contains(&scale),
"the model lands on {scale}, the reference measured 10.28"
);
let travel = TextSwapTransition::FlyThrough.easing().exit.ease(0.95);
assert!(
(0.97..0.99).contains(&travel),
"the curve has spent {travel} of the travel at 95% of the window"
);
}
#[test]
fn each_preset_reaches_its_motion_targets_by_the_end_of_each_window() {
for preset in PRESETS {
let motion = preset.motion();
let easing = preset.easing();
let defaults = preset.defaults();
let exit = defaults.exit_duration;
let enter = defaults.enter_duration;
let at_rest = exit_channels(0.0, exit, &motion, easing.exit);
assert!((at_rest.y).abs() < 1e-6, "{preset:?} y at rest");
assert!(
(at_rest.scale - 1.0).abs() < 1e-6,
"{preset:?} scale at rest"
);
assert!(
(at_rest.opacity - 1.0).abs() < 1e-6,
"{preset:?} opacity at rest"
);
assert!((at_rest.blur).abs() < 1e-6, "{preset:?} blur at rest");
let gone = exit_channels(exit, exit, &motion, easing.exit);
assert!((gone.y - motion.exit_y).abs() < 1e-4, "{preset:?} exit y");
assert!(
(gone.scale - motion.exit_scale).abs() < 0.05,
"{preset:?} exit scale {} vs {}",
gone.scale,
motion.exit_scale
);
assert!((gone.opacity).abs() < 1e-6, "{preset:?} exit opacity");
assert!(
(gone.blur - motion.exit_blur).abs() < 1e-4,
"{preset:?} exit blur"
);
let past = exit_channels(exit + 50.0, exit, &motion, easing.exit);
assert!((past.opacity).abs() < 1e-6, "{preset:?} stays gone");
let arriving = enter_channels(0.0, enter, &motion, easing.enter);
assert!(
(arriving.y - motion.enter_y).abs() < 1e-6,
"{preset:?} enter y"
);
assert!(
(arriving.scale - motion.enter_scale).abs() < 1e-6,
"{preset:?} enter scale"
);
assert!((arriving.opacity).abs() < 1e-6, "{preset:?} enter opacity");
assert!(
(arriving.blur - motion.enter_blur).abs() < 1e-6,
"{preset:?} enter blur"
);
let landed = enter_channels(enter, enter, &motion, easing.enter);
assert!((landed.y).abs() < 1e-6, "{preset:?} landed y");
assert!((landed.scale - 1.0).abs() < 1e-6, "{preset:?} landed scale");
assert!(
(landed.opacity - 1.0).abs() < 1e-6,
"{preset:?} landed opacity"
);
assert!((landed.blur).abs() < 1e-6, "{preset:?} landed blur");
}
}
#[test]
fn the_rush_holds_solid_and_then_goes() {
let motion = TextSwapTransition::FlyThrough.motion();
let easing = TextSwapTransition::FlyThrough.easing();
let duration = TextSwapTransition::FlyThrough.defaults().exit_duration;
let fade_from = motion.exit_fade_start * duration;
assert!((fade_from - 14.4).abs() < 1e-6);
let mut previous = 1.0;
for frame in 0..=200 {
let frame = frame as f32 / 10.0;
let channels = exit_channels(frame, duration, &motion, easing.exit);
assert!(
channels.opacity <= previous + 1e-6,
"opacity rose at {frame}"
);
if frame <= fade_from {
assert!(
(channels.opacity - 1.0).abs() < 1e-6,
"at {frame} the line is already at {}",
channels.opacity
);
}
previous = channels.opacity;
}
assert!(
(previous).abs() < 1e-6,
"the line is still visible at the end"
);
}
#[test]
fn the_cut_pops_on_the_step_function() {
let motion = TextSwapTransition::Cut.motion();
let easing = TextSwapTransition::Cut.easing();
let duration = TextSwapTransition::Cut.defaults().exit_duration;
let before = exit_channels(duration - 0.001, duration, &motion, easing.exit);
assert!((before.opacity - 1.0).abs() < 1e-6);
assert!((before.scale - 1.0).abs() < 1e-6);
let after = exit_channels(duration, duration, &motion, easing.exit);
assert!((after.opacity).abs() < 1e-6);
let waiting = enter_channels(duration - 0.001, duration, &motion, easing.enter);
assert!((waiting.opacity).abs() < 1e-6);
let shown = enter_channels(duration, duration, &motion, easing.enter);
assert!((shown.opacity - 1.0).abs() < 1e-6);
}
#[test]
fn the_shutters_samples_are_merged_where_they_coincide() {
let motion = TextSwapTransition::FlyThrough.motion();
let easing = TextSwapTransition::FlyThrough.easing();
let duration = TextSwapTransition::FlyThrough.defaults().exit_duration;
let at_rest = shutter_samples(0.0, duration, &motion, easing.exit);
assert_eq!(at_rest.len(), 1, "{at_rest:?}");
assert!((at_rest[0].0 - 0.0).abs() < 1e-6);
assert!((at_rest[0].1 - 1.0).abs() < 1e-6);
let rushing = shutter_samples(18.0, duration, &motion, easing.exit);
assert_eq!(rushing.len(), 18, "{rushing:?}");
let total: f32 = rushing.iter().map(|(_, share)| share).sum();
assert!((total - 1.0).abs() < 1e-6, "shares sum to {total}");
for (_, share) in &rushing {
assert!((share - 1.0 / 18.0).abs() < 1e-6);
}
let newest = rushing[0].0;
let oldest = rushing[rushing.len() - 1].0;
assert!((newest - oldest - SHUTTER * 17.0 / 18.0).abs() < 1e-6);
for preset in PRESETS {
if preset == TextSwapTransition::FlyThrough {
continue;
}
let motion = preset.motion();
let samples = shutter_samples(
3.0,
preset.defaults().exit_duration,
&motion,
preset.easing().exit,
);
assert_eq!(samples.len(), 1, "{preset:?} {samples:?}");
assert!((samples[0].1 - 1.0).abs() < 1e-6);
}
}
#[test]
fn line_slots_centre_the_line_and_keep_the_trailing_margin() {
let slots = LineSlots::new(vec![40.0, 60.0, 20.0], 10.0, 10.0);
assert!((slots.gap - 10.0).abs() < 1e-6);
assert_eq!(slots.widths.len(), 3);
assert!(
(slots.centers[0] + 55.0).abs() < 1e-6,
"{:?}",
slots.centers
);
assert!((slots.centers[1] - 5.0).abs() < 1e-6);
assert!((slots.centers[2] - 55.0).abs() < 1e-6);
let ink_left = slots.centers[0] - 20.0;
let ink_right = slots.centers[2] + 10.0;
assert!(
(ink_left + ink_right - (-10.0)).abs() < 1e-6,
"{ink_left}..{ink_right}"
);
assert!((ink_left + 75.0).abs() < 1e-6);
assert!((ink_right - 65.0).abs() < 1e-6);
}
#[test]
fn a_one_segment_line_is_centred_on_its_ink_when_it_has_no_margin() {
let slots = LineSlots::new(vec![100.0], 18.0, 0.0);
assert!((slots.centers[0]).abs() < 1e-6);
assert!((slots.trailing).abs() < 1e-6);
}
#[test]
fn a_zero_length_window_leaves_each_channel_where_it_started() {
let motion = TextSwapTransition::FadeThrough.motion();
let easing = TextSwapTransition::FadeThrough.easing();
let exit = exit_channels(0.0, 0.0, &motion, easing.exit);
assert!(
(exit.opacity - 1.0).abs() < 1e-6,
"the from-line stays visible"
);
assert!((exit.scale - 1.0).abs() < 1e-6, "and stays where it is");
let enter = enter_channels(0.0, 0.0, &motion, easing.enter);
assert!(
(enter.opacity - 1.0).abs() < 1e-6,
"the to-line is simply there"
);
assert!((enter.scale - 1.0).abs() < 1e-6);
assert!((enter.blur).abs() < 1e-6);
}
}