use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::{
LayoutCache, LineLayout, PlacedLine, PlacedWord, RenderCtx, text_node_centered, words,
};
use crate::motion::CubicBezier;
const EXIT_RUSH: CubicBezier = CubicBezier::new(1.0, 0.65, 0.85, 1.0);
const ENTER_SOFT: CubicBezier = CubicBezier::new(0.2, 0.6, 0.35, 1.0);
const MODERATE: CubicBezier = CubicBezier::new(0.2, 0.6, 0.35, 1.0);
pub const SHUTTER_SAMPLES: usize = 18;
const BACKDROP: &str = "#0d1117";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SwapUnit {
Block,
Word,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SwapPreset {
#[default]
FlyThrough,
FadeThrough,
Crossfade,
SharedAxisY,
SharedAxisZ,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TextSwapPreset {
pub unit: SwapUnit,
pub exit_duration: f32,
pub enter_duration: f32,
pub exit_ease: CubicBezier,
pub enter_ease: CubicBezier,
pub exit_y: f32,
pub enter_y: f32,
pub exit_scale: f32,
pub enter_scale: f32,
pub exit_blur: f32,
pub enter_blur: f32,
pub exit_fade_start: f32,
pub shutter: bool,
}
pub const PRESETS: [TextSwapPreset; 5] = [
TextSwapPreset {
unit: SwapUnit::Block,
exit_duration: 20.0,
enter_duration: 16.0,
exit_ease: EXIT_RUSH,
enter_ease: ENTER_SOFT,
exit_y: 0.0,
enter_y: 0.0,
exit_scale: 12.0,
enter_scale: 0.82,
exit_blur: 0.5,
enter_blur: 9.0,
exit_fade_start: 0.72,
shutter: true,
},
TextSwapPreset {
unit: SwapUnit::Block,
exit_duration: 8.0,
enter_duration: 13.0,
exit_ease: MODERATE,
enter_ease: MODERATE,
exit_y: -4.0,
enter_y: 6.0,
exit_scale: 1.0,
enter_scale: 0.99,
exit_blur: 0.0,
enter_blur: 2.0,
exit_fade_start: 0.0,
shutter: false,
},
TextSwapPreset {
unit: SwapUnit::Word,
exit_duration: 15.0,
enter_duration: 21.0,
exit_ease: MODERATE,
enter_ease: MODERATE,
exit_y: -6.0,
enter_y: 8.0,
exit_scale: 1.0,
enter_scale: 1.0,
exit_blur: 0.0,
enter_blur: 0.0,
exit_fade_start: 0.0,
shutter: false,
},
TextSwapPreset {
unit: SwapUnit::Block,
exit_duration: 10.0,
enter_duration: 14.0,
exit_ease: MODERATE,
enter_ease: MODERATE,
exit_y: -24.0,
enter_y: 24.0,
exit_scale: 1.0,
enter_scale: 1.0,
exit_blur: 0.0,
enter_blur: 0.0,
exit_fade_start: 0.0,
shutter: false,
},
TextSwapPreset {
unit: SwapUnit::Block,
exit_duration: 11.0,
enter_duration: 16.0,
exit_ease: MODERATE,
enter_ease: MODERATE,
exit_y: 0.0,
enter_y: 0.0,
exit_scale: 1.06,
enter_scale: 0.9,
exit_blur: 1.0,
enter_blur: 2.0,
exit_fade_start: 0.0,
shutter: false,
},
];
impl SwapPreset {
pub fn def(self) -> TextSwapPreset {
match self {
Self::FlyThrough => PRESETS[0],
Self::FadeThrough => PRESETS[1],
Self::Crossfade => PRESETS[2],
Self::SharedAxisY => PRESETS[3],
Self::SharedAxisZ => PRESETS[4],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TextSwapDurationOptions {
pub exit_stagger: usize,
pub enter_stagger: usize,
pub overlap: usize,
pub micro_delay: usize,
pub speed: f32,
}
impl Default for TextSwapDurationOptions {
fn default() -> Self {
Self {
exit_stagger: 1,
enter_stagger: 2,
overlap: 1,
micro_delay: 2,
speed: 1.0,
}
}
}
fn effective_speed(speed: f32) -> f32 {
if speed.is_finite() && speed > 0.0 {
speed
} else {
1.0
}
}
pub fn enter_start(preset: SwapPreset, from_text: &str, options: &TextSwapDurationOptions) -> f32 {
let def = preset.def();
let exit_duration = def.exit_duration / effective_speed(options.speed);
let exit_segments = segment_count(from_text, def.unit);
(exit_duration + (exit_segments - 1) as f32 * options.exit_stagger as f32
- options.overlap as f32
+ options.micro_delay as f32)
.max(0.0)
}
pub fn segment_count(text: &str, unit: SwapUnit) -> usize {
match unit {
SwapUnit::Word => words(text).len().max(1),
SwapUnit::Block => 1,
}
}
pub fn get_text_swap_duration(
from_text: &str,
to_text: &str,
preset: SwapPreset,
options: &TextSwapDurationOptions,
) -> usize {
let def = preset.def();
let speed = effective_speed(options.speed);
let exit_duration = def.exit_duration / speed;
let enter_duration = def.enter_duration / speed;
let exit_segments = segment_count(from_text, def.unit);
let enter_segments = segment_count(to_text, def.unit);
let start = enter_start(preset, from_text, options);
let exit_end = exit_duration + (exit_segments - 1) as f32 * options.exit_stagger as f32;
let enter_end =
start + enter_duration + (enter_segments - 1) as f32 * options.enter_stagger as f32;
exit_end.max(enter_end).ceil().max(0.0) as usize
}
pub fn perspective_scale(travel: f32, max_scale: f32) -> f32 {
let max_scale = if max_scale.abs() < f32::EPSILON {
1.0
} else {
max_scale
};
1.0 / (1.0 - (1.0 - 1.0 / max_scale) * travel.clamp(0.0, 0.9999))
}
pub fn exit_opacity(def: &TextSwapPreset, local: f32, exit_duration: f32) -> f32 {
let fade_from = def.exit_fade_start * exit_duration;
if local <= fade_from {
1.0
} else {
1.0 - (local - fade_from) / (exit_duration - fade_from).max(1.0)
}
}
pub fn shutter_sample_opacity(def: &TextSwapPreset, travel: f32) -> f32 {
let fade = if travel <= def.exit_fade_start {
1.0
} else {
1.0 - (travel - def.exit_fade_start) / (1.0 - def.exit_fade_start).max(0.001)
};
fade / SHUTTER_SAMPLES as f32
}
fn natural_line_height(ctx: &RenderCtx<'_>, family: &str, size: f32) -> f32 {
ctx.meter(family)
.map_or(size * 1.2, |meter| meter.line_height(size))
}
fn pivot_transform(px: f32, py: f32, y: f32, scale: f32) -> String {
format!(
"translate({:.4} {:.4}) scale({:.6}) translate({:.4} {:.4})",
px,
py + y,
scale,
-px,
-py
)
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextSwapConfig {
pub from_text: String,
pub to_text: String,
pub transition: SwapPreset,
pub exit_stagger: usize,
pub enter_stagger: usize,
pub overlap: usize,
pub micro_delay: usize,
pub speed: f32,
pub delay: usize,
pub font_size: f32,
pub font_weight: u16,
pub tracking_em: f32,
pub color: String,
}
impl Default for TextSwapConfig {
fn default() -> Self {
Self {
from_text: "The Old Way of Thinking".to_owned(),
to_text: "The New Way of Building".to_owned(),
transition: SwapPreset::FlyThrough,
exit_stagger: 1,
enter_stagger: 2,
overlap: 1,
micro_delay: 2,
speed: 1.0,
delay: 0,
font_size: 72.0,
font_weight: 600,
tracking_em: -0.03,
color: "#e6edf3".to_owned(),
}
}
}
impl TextSwapConfig {
pub fn options(&self) -> TextSwapDurationOptions {
TextSwapDurationOptions {
exit_stagger: self.exit_stagger,
enter_stagger: self.enter_stagger,
overlap: self.overlap,
micro_delay: self.micro_delay,
speed: self.speed,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TextSwap {
pub config: TextSwapConfig,
layout_from: LayoutCache,
layout_to: LayoutCache,
}
impl TextSwap {
pub fn new(config: TextSwapConfig) -> Self {
Self {
config,
layout_from: LayoutCache::new(),
layout_to: LayoutCache::new(),
}
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "text-swap",
name: "Text Swap",
summary: "One line rushes out and another takes its place, on five presets.",
feature: "text-swap",
}
}
fn word_node(
&self,
word: &PlacedWord,
center_x: f32,
baseline: f32,
family: &str,
) -> Svgr<'static> {
text_node_centered(
&word.text,
center_x + word.x,
baseline,
family.to_owned(),
self.config.font_size,
self.config.font_weight,
self.config.tracking_em,
self.config.color.clone(),
1.0,
)
}
fn segment_body(
&self,
layout: &LineLayout,
unit: SwapUnit,
segment: usize,
center_x: f32,
baseline: f32,
family: &str,
) -> (f32, Svgr<'static>) {
let Some(line) = layout.lines.first() else {
return (center_x, Svgr::empty());
};
match unit {
SwapUnit::Block => (center_x, self.line_body(line, center_x, baseline, family)),
SwapUnit::Word => match line.words.get(segment) {
Some(word) => (
center_x + word.x,
self.word_node(word, center_x, baseline, family),
),
None => (center_x, Svgr::empty()),
},
}
}
fn line_body(
&self,
line: &PlacedLine,
center_x: f32,
baseline: f32,
family: &str,
) -> Svgr<'static> {
let nodes: Vec<Svgr<'static>> = line
.words
.iter()
.map(|word| self.word_node(word, center_x, baseline, family))
.collect();
Svgr::from(nodes)
}
}
impl Component for TextSwap {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
get_text_swap_duration(
&self.config.from_text,
&self.config.to_text,
self.config.transition,
&self.config.options(),
)
}
fn background(&self) -> Option<&'static str> {
Some(BACKDROP)
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
let def = config.transition.def();
let speed = effective_speed(config.speed);
let exit_duration = def.exit_duration / speed;
let enter_duration = def.enter_duration / speed;
let start = enter_start(config.transition, &config.from_text, &config.options());
let frame = (ctx.frame() - config.delay as f32).max(0.0);
let family = ctx.sans();
let center_x = ctx.width() / 2.0;
let center_y = ctx.height() / 2.0;
let line_step = natural_line_height(ctx, &family, config.font_size);
let from_layout = self.layout_from.resolve(
ctx,
&family,
&config.from_text,
config.font_size,
f32::INFINITY,
line_step,
);
let to_layout = self.layout_to.resolve(
ctx,
&family,
&config.to_text,
config.font_size,
f32::INFINITY,
line_step,
);
let from_baseline = from_layout.baseline_y(0, center_y);
let to_baseline = to_layout.baseline_y(0, center_y);
let exit_segments = segment_count(&config.from_text, def.unit);
let enter_segments = segment_count(&config.to_text, def.unit);
let mut nodes: Vec<Svgr<'_>> = Vec::new();
for segment in 0..exit_segments {
let segment_start = segment as f32 * config.exit_stagger as f32;
let local = (frame - segment_start).clamp(0.0, exit_duration);
let travel = def.exit_ease.ease(local / exit_duration);
let opacity = exit_opacity(&def, local, exit_duration);
let y = def.exit_y * travel;
let (pivot_x, body) = self.segment_body(
&from_layout,
def.unit,
segment,
center_x,
from_baseline,
&family,
);
if def.shutter && travel > 0.0 && opacity > 0.0 {
let travel_prev = def
.exit_ease
.ease((frame - 1.0 - segment_start).clamp(0.0, exit_duration) / exit_duration);
for sample in 0..SHUTTER_SAMPLES {
let blend = sample as f32 / (SHUTTER_SAMPLES - 1) as f32;
let sample_travel = travel_prev + (travel - travel_prev) * blend;
let sample_opacity = shutter_sample_opacity(&def, sample_travel);
let transform = pivot_transform(
pivot_x,
center_y,
y,
perspective_scale(sample_travel, def.exit_scale),
);
let body = body.clone();
nodes.push(
fframes::svgr!(<g transform={transform} opacity={sample_opacity}>{body}</g>),
);
}
continue;
}
let scale = if def.shutter {
perspective_scale(travel, def.exit_scale)
} else {
1.0 + (def.exit_scale - 1.0) * travel
};
let transform = pivot_transform(pivot_x, center_y, y, scale);
nodes.push(fframes::svgr!(<g transform={transform} opacity={opacity}>{body}</g>));
}
for segment in 0..enter_segments {
let segment_start = start + segment as f32 * config.enter_stagger as f32;
let local = (frame - segment_start).clamp(0.0, enter_duration);
let progress = def.enter_ease.ease(local / enter_duration);
let y = def.enter_y * (1.0 - progress);
let scale = def.enter_scale + (1.0 - def.enter_scale) * progress;
let (pivot_x, body) = self.segment_body(
&to_layout,
def.unit,
segment,
center_x,
to_baseline,
&family,
);
let transform = pivot_transform(pivot_x, center_y, y, scale);
nodes.push(fframes::svgr!(<g transform={transform} opacity={progress}>{body}</g>));
}
let nodes = Svgr::from(nodes);
fframes::svgr!(<g>{nodes}</g>)
}
}
#[cfg(test)]
mod tests {
use super::*;
const FROM: &str = "The Old Way of Thinking";
const TO: &str = "The New Way of Building";
#[test]
fn presets_table_is_ported_verbatim() {
assert_eq!(PRESETS.len(), 5);
let fly = PRESETS[0];
assert_eq!(fly.unit, SwapUnit::Block);
assert_eq!(fly.exit_duration, 20.0);
assert_eq!(fly.enter_duration, 16.0);
assert_eq!(fly.enter_scale, 0.82);
assert_eq!(fly.exit_scale, 12.0);
assert_eq!(fly.exit_blur, 0.5);
assert_eq!(fly.enter_blur, 9.0);
assert_eq!(fly.exit_fade_start, 0.72);
assert!(fly.shutter);
let fade = PRESETS[1];
assert_eq!(fade.exit_y, -4.0);
assert_eq!(fade.enter_y, 6.0);
assert_eq!(fade.enter_scale, 0.99);
assert!(!fade.shutter);
assert_eq!(PRESETS[2].unit, SwapUnit::Word);
assert_eq!(PRESETS[2].exit_y, -6.0);
assert_eq!(PRESETS[2].enter_y, 8.0);
assert_eq!(PRESETS[3].exit_y, -24.0);
assert_eq!(PRESETS[3].enter_y, 24.0);
assert_eq!(PRESETS[4].exit_scale, 1.06);
assert_eq!(PRESETS[4].enter_scale, 0.9);
assert_eq!(SwapPreset::default(), SwapPreset::FlyThrough);
assert_eq!(SwapPreset::Crossfade.def(), PRESETS[2]);
}
#[test]
fn duration_matches_the_ported_formula_for_every_preset() {
let options = TextSwapDurationOptions::default();
assert_eq!(
get_text_swap_duration(FROM, TO, SwapPreset::FlyThrough, &options),
37
);
assert_eq!(
get_text_swap_duration(FROM, TO, SwapPreset::FadeThrough, &options),
22
);
assert_eq!(
get_text_swap_duration(FROM, TO, SwapPreset::Crossfade, &options),
49
);
assert_eq!(
get_text_swap_duration(FROM, TO, SwapPreset::SharedAxisY, &options),
25
);
assert_eq!(
get_text_swap_duration(FROM, TO, SwapPreset::SharedAxisZ, &options),
28
);
}
#[test]
fn duration_scales_with_speed() {
let fast = TextSwapDurationOptions {
speed: 2.0,
..TextSwapDurationOptions::default()
};
assert_eq!(
get_text_swap_duration(FROM, TO, SwapPreset::FlyThrough, &fast),
19
);
}
#[test]
fn degenerate_speed_falls_back_to_one() {
for speed in [0.0, -3.0, f32::NAN, f32::INFINITY] {
let options = TextSwapDurationOptions {
speed,
..TextSwapDurationOptions::default()
};
assert_eq!(effective_speed(speed), 1.0);
assert_eq!(
get_text_swap_duration(FROM, TO, SwapPreset::FlyThrough, &options),
37
);
}
}
#[test]
fn enter_start_is_floored_at_zero() {
let options = TextSwapDurationOptions {
exit_stagger: 1,
enter_stagger: 2,
overlap: 500,
micro_delay: 2,
speed: 1.0,
};
assert!(enter_start(SwapPreset::FlyThrough, FROM, &options).abs() < 1e-6);
}
#[test]
fn segment_count_never_drops_below_one() {
assert_eq!(segment_count("", SwapUnit::Word), 1);
assert_eq!(segment_count(" ", SwapUnit::Word), 1);
assert_eq!(segment_count("a b c", SwapUnit::Word), 3);
assert_eq!(segment_count("a b c", SwapUnit::Block), 1);
assert_eq!(segment_count("", SwapUnit::Block), 1);
}
#[test]
fn perspective_scale_creeps_then_blows_up() {
assert!((perspective_scale(0.0, 12.0) - 1.0).abs() < 1e-6);
let peak = perspective_scale(1.0, 12.0);
assert!(peak > 11.0 && peak < 12.0, "got {peak}");
assert!((perspective_scale(-5.0, 12.0) - 1.0).abs() < 1e-6);
let mut previous = 0.0;
for step in 0..=100 {
let value = perspective_scale(step as f32 / 100.0, 12.0);
assert!(value >= previous, "not monotonic at {step}");
previous = value;
}
}
#[test]
fn exit_opacity_holds_then_falls() {
let fly = PRESETS[0];
assert!((exit_opacity(&fly, 0.0, 20.0) - 1.0).abs() < 1e-6);
assert!((exit_opacity(&fly, 14.4, 20.0) - 1.0).abs() < 1e-6);
assert!(exit_opacity(&fly, 17.0, 20.0) < 1.0);
assert!((exit_opacity(&fly, 20.0, 20.0) - 0.0).abs() < 1e-6);
let cross = PRESETS[2];
assert!((exit_opacity(&cross, 0.0, 15.0) - 1.0).abs() < 1e-6);
assert!((exit_opacity(&cross, 14.0, 15.0) - (1.0 - 14.0 / 15.0)).abs() < 1e-6);
assert!(exit_opacity(&cross, 15.0, 15.0).abs() < 1e-6);
}
#[test]
fn shutter_samples_split_the_fade_eighteen_ways() {
let fly = PRESETS[0];
assert!((shutter_sample_opacity(&fly, 0.0) - 1.0 / 18.0).abs() < 1e-6);
assert!((shutter_sample_opacity(&fly, 0.72) - 1.0 / 18.0).abs() < 1e-6);
assert!(shutter_sample_opacity(&fly, 1.0).abs() < 1e-6);
let sum: f32 = (0..SHUTTER_SAMPLES)
.map(|_| shutter_sample_opacity(&fly, 0.0))
.sum();
assert!((sum - 1.0).abs() < 1e-5, "got {sum}");
}
#[test]
fn component_duration_and_meta_match_the_config() {
let component = TextSwap::default();
assert_eq!(component.duration(), 37);
assert_eq!(component.meta().id, "text-swap");
assert_eq!(component.background(), Some("#0d1117"));
let cross = TextSwap::new(TextSwapConfig {
transition: SwapPreset::Crossfade,
..TextSwapConfig::default()
});
assert_eq!(cross.duration(), 49);
}
fn document(body: Svgr<'_>) -> Svgr<'_> {
fframes::svgr!(
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" width="1920"
height="1080">
<rect x="0" y="0" width="1920" height="1080" fill="#000000" />
{body}
</svg>
)
}
#[test]
fn every_preset_frame_converts_to_a_renderable_tree() {
let meters = crate::embedded_meters();
for preset in [
SwapPreset::FlyThrough,
SwapPreset::FadeThrough,
SwapPreset::Crossfade,
SwapPreset::SharedAxisY,
SwapPreset::SharedAxisZ,
] {
let component = TextSwap::new(TextSwapConfig {
transition: preset,
..TextSwapConfig::default()
});
let duration = component.duration();
let mut saw_transform = false;
for local in 0..duration {
let mut frame = fframes::Frame::new(local, local, 30);
let info = crate::ctx::ComponentFrame {
local: local as f32,
global: local,
fps: 30.0,
duration,
width: 1920.0,
height: 1080.0,
};
let mut ctx =
RenderCtx::new(&mut frame, crate::core::Fonts::default(), &meters, info);
let svg = document(component.show(&mut ctx));
let dump = format!("{svg}");
saw_transform |= dump.contains("translate(");
assert!(
!dump.contains("NaN"),
"{preset:?} frame {local} emitted a NaN"
);
let mut cache = fframes::usvgr::Cache::default();
let converted = svg.into_svg_tree(
&fframes::usvgr::Options::default(),
&mut cache,
&fframes::usvgr::fontdb::Database::new(),
);
assert!(
converted.is_ok(),
"{preset:?} frame {local} did not convert: {:?}",
converted.err()
);
}
assert!(
saw_transform,
"{preset:?} emitted no transform on any frame"
);
}
}
}