use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::{LayoutCache, RenderCtx, text_node_centered, words};
use crate::motion::{CubicBezier, EASE_ZOOM_IN, clamp01, interpolate};
const PUSH_EASE: CubicBezier = CubicBezier::new(0.25, 1.0, 0.5, 1.0);
const WORD_EASE: CubicBezier = CubicBezier::new(0.22, 0.8, 0.36, 1.0);
const BACKDROP: &str = "#0d1117";
fn natural_line_height(ctx: &RenderCtx<'_>, family: &str, size: f32) -> f32 {
ctx.meter(family)
.map_or(size * 1.2, |meter| meter.line_height(size))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TextRevealDurations {
pub intro_duration: usize,
pub hold_duration: usize,
pub recede_duration: usize,
pub assemble_duration: usize,
pub word_delay: usize,
pub word_duration: usize,
pub word_stagger: usize,
}
impl Default for TextRevealDurations {
fn default() -> Self {
Self {
intro_duration: 6,
hold_duration: 12,
recede_duration: 14,
assemble_duration: 30,
word_delay: 7,
word_duration: 14,
word_stagger: 4,
}
}
}
impl TextRevealDurations {
pub fn recede_start(&self) -> usize {
self.intro_duration.saturating_add(self.hold_duration)
}
}
pub fn word_start(durations: &TextRevealDurations, trailing_index: usize) -> usize {
durations
.recede_start()
.saturating_add(durations.word_delay)
.saturating_add(trailing_index.saturating_mul(durations.word_stagger))
}
pub fn get_text_reveal_duration(text: &str, durations: &TextRevealDurations) -> usize {
let trailing = words(text).len().saturating_sub(1);
let recede_start = durations.recede_start();
let words_end = if trailing == 0 {
0
} else {
durations
.word_delay
.saturating_add((trailing - 1).saturating_mul(durations.word_stagger))
.saturating_add(durations.word_duration)
};
recede_start.saturating_add(
durations
.assemble_duration
.max(words_end)
.max(durations.recede_duration),
)
}
pub fn lead_ratio(lead_width: f32, line_width: f32) -> f32 {
if line_width > 0.0 {
(lead_width / 2.0) / line_width
} else {
0.5
}
}
pub fn slide_distance(line_width: f32, lead_ratio: f32) -> f32 {
line_width * (0.5 - lead_ratio)
}
pub fn reveal_scale(
frame: f32,
durations: &TextRevealDurations,
initial_scale: f32,
push_scale: f32,
) -> f32 {
let intro = durations.intro_duration.max(1) as f32;
let recede_start = durations.recede_start() as f32;
let full_scale = initial_scale * push_scale;
if frame < intro {
initial_scale
} else if frame < recede_start {
initial_scale
+ (full_scale - initial_scale)
* PUSH_EASE.ease(clamp01(
(frame - intro) / durations.hold_duration.max(1) as f32,
))
} else {
full_scale
+ (1.0 - full_scale)
* EASE_ZOOM_IN.ease(clamp01(
(frame - recede_start) / durations.recede_duration.max(1) as f32,
))
}
}
pub fn slide_offset(frame: f32, durations: &TextRevealDurations, slide_distance: f32) -> f32 {
let recede_start = durations.recede_start() as f32;
let progress = EASE_ZOOM_IN.ease(clamp01(
(frame - recede_start) / durations.assemble_duration.max(1) as f32,
));
slide_distance * (1.0 - progress)
}
pub fn lead_opacity(frame: f32, durations: &TextRevealDurations) -> f32 {
EASE_ZOOM_IN.ease(clamp01(frame / durations.intro_duration.max(1) as f32))
}
pub fn word_push(frame: f32, durations: &TextRevealDurations, trailing_index: usize) -> f32 {
let start = word_start(durations, trailing_index);
WORD_EASE.ease(clamp01(
(frame - start as f32) / durations.word_duration.max(1) as f32,
))
}
pub fn word_push_offset(
frame: f32,
durations: &TextRevealDurations,
trailing_index: usize,
word_push_em: f32,
font_size: f32,
) -> f32 {
(1.0 - word_push(frame, durations, trailing_index)) * word_push_em * font_size
}
pub fn word_opacity(
frame: f32,
durations: &TextRevealDurations,
trailing_index: usize,
word_fade: usize,
) -> f32 {
let start = word_start(durations, trailing_index) as f32;
interpolate(frame, [start, start + word_fade.max(1) as f32], [0.0, 1.0])
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextRevealConfig {
pub text: String,
pub intro_duration: usize,
pub hold_duration: usize,
pub recede_duration: usize,
pub assemble_duration: usize,
pub word_delay: usize,
pub word_duration: usize,
pub word_stagger: usize,
pub word_fade: usize,
pub word_push_em: f32,
pub initial_scale: f32,
pub push_scale: f32,
pub delay: usize,
pub font_size: f32,
pub font_weight: u16,
pub tracking_em: f32,
pub color: String,
}
impl Default for TextRevealConfig {
fn default() -> Self {
Self {
text: "Make Something People Love".to_owned(),
intro_duration: 6,
hold_duration: 12,
recede_duration: 14,
assemble_duration: 30,
word_delay: 7,
word_duration: 14,
word_stagger: 4,
word_fade: 2,
word_push_em: 0.5,
initial_scale: 2.3,
push_scale: 1.06,
delay: 0,
font_size: 72.0,
font_weight: 600,
tracking_em: -0.03,
color: "#e6edf3".to_owned(),
}
}
}
impl TextRevealConfig {
pub fn durations(&self) -> TextRevealDurations {
TextRevealDurations {
intro_duration: self.intro_duration,
hold_duration: self.hold_duration,
recede_duration: self.recede_duration,
assemble_duration: self.assemble_duration,
word_delay: self.word_delay,
word_duration: self.word_duration,
word_stagger: self.word_stagger,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TextReveal {
pub config: TextRevealConfig,
layout: LayoutCache,
}
impl TextReveal {
pub fn new(config: TextRevealConfig) -> Self {
Self {
config,
layout: LayoutCache::new(),
}
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "text-reveal",
name: "Text Reveal",
summary: "A lead word zooms in and recedes while the line slides into place.",
feature: "text-reveal",
}
}
}
impl Component for TextReveal {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
get_text_reveal_duration(&self.config.text, &self.config.durations())
}
fn background(&self) -> Option<&'static str> {
Some(BACKDROP)
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
let durations = config.durations();
let family = ctx.sans();
let line_step = natural_line_height(ctx, &family, config.font_size);
let layout = self.layout.resolve(
ctx,
&family,
&config.text,
config.font_size,
f32::INFINITY,
line_step,
);
let Some(line) = layout.lines.first() else {
return Svgr::empty();
};
let Some(lead) = line.words.first() else {
return Svgr::empty();
};
let center_x = ctx.width() / 2.0;
let center_y = ctx.height() / 2.0;
let baseline = layout.baseline_y(0, center_y);
let ratio = lead_ratio(lead.width, line.width);
let distance = slide_distance(line.width, ratio);
let pivot_x = line.left(center_x) + lead.width / 2.0;
let frame = (ctx.frame() - config.delay as f32).max(0.0);
let scale = reveal_scale(frame, &durations, config.initial_scale, config.push_scale);
let slide_x = slide_offset(frame, &durations, distance);
let opacity = lead_opacity(frame, &durations);
let transform = format!(
"translate({:.4} {:.4}) scale({:.6}) translate({:.4} {:.4})",
pivot_x + slide_x,
baseline,
scale,
-pivot_x,
-baseline
);
let mut nodes: Vec<Svgr<'_>> = Vec::with_capacity(line.words.len());
for (index, word) in line.words.iter().enumerate() {
let alpha = if index == 0 {
opacity
} else {
word_opacity(frame, &durations, index - 1, config.word_fade)
};
let node = text_node_centered(
&word.text,
center_x + word.x,
baseline,
family.clone(),
config.font_size,
config.font_weight,
config.tracking_em,
config.color.clone(),
alpha,
);
if index == 0 {
nodes.push(node);
} else {
let push = format!(
"translate({:.4} 0)",
word_push_offset(
frame,
&durations,
index - 1,
config.word_push_em,
config.font_size,
)
);
nodes.push(fframes::svgr!(<g transform={push}>{node}</g>));
}
}
let nodes = Svgr::from(nodes);
fframes::svgr!(<g transform={transform}>{nodes}</g>)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn duration_matches_the_ported_formula() {
let durations = TextRevealDurations::default();
assert_eq!(
get_text_reveal_duration("Make Something People Love", &durations),
48
);
assert_eq!(get_text_reveal_duration("Hello", &durations), 48);
assert_eq!(get_text_reveal_duration("", &durations), 48);
}
#[test]
fn duration_is_driven_by_the_trailing_words_when_they_outlast_the_slide() {
let durations = TextRevealDurations::default();
let long = "a b c d e f g h i j k l";
assert_eq!(get_text_reveal_duration(long, &durations), 79);
}
#[test]
fn lead_ratio_is_the_lead_centre_over_the_line_width() {
assert!((lead_ratio(100.0, 400.0) - 0.125).abs() < 1e-6);
assert!((lead_ratio(100.0, 0.0) - 0.5).abs() < 1e-6);
}
#[test]
fn slide_distance_puts_the_lead_centre_on_the_canvas_centre() {
let ratio = lead_ratio(100.0, 400.0);
let distance = slide_distance(400.0, ratio);
assert!((distance - 150.0).abs() < 1e-5, "got {distance}");
assert!((distance + (-150.0)).abs() < 1e-5);
}
#[test]
fn reveal_scale_walks_the_three_beats() {
let durations = TextRevealDurations::default();
assert!((reveal_scale(0.0, &durations, 2.3, 1.06) - 2.3).abs() < 1e-5);
assert!((reveal_scale(6.0, &durations, 2.3, 1.06) - 2.3).abs() < 1e-5);
let mid = reveal_scale(12.0, &durations, 2.3, 1.06);
assert!(mid > 2.3, "expected drift upward, got {mid}");
let landed = reveal_scale(18.0 + 14.0, &durations, 2.3, 1.06);
assert!((landed - 1.0).abs() < 1e-5, "got {landed}");
assert!((reveal_scale(1000.0, &durations, 2.3, 1.06) - 1.0).abs() < 1e-5);
}
#[test]
fn slide_runs_on_the_longer_clock_and_still_travels_after_the_recede() {
let durations = TextRevealDurations::default();
assert!((slide_offset(18.0, &durations, 150.0) - 150.0).abs() < 1e-5);
let partial = slide_offset(32.0, &durations, 150.0);
assert!(partial > 0.0 && partial < 150.0, "got {partial}");
assert!((slide_offset(48.0, &durations, 150.0) - 0.0).abs() < 1e-4);
}
#[test]
fn lead_opacity_fades_on_the_intro_clock() {
let durations = TextRevealDurations::default();
assert!((lead_opacity(0.0, &durations) - 0.0).abs() < 1e-6);
assert!((lead_opacity(6.0, &durations) - 1.0).abs() < 1e-4);
assert!(lead_opacity(3.0, &durations) > 0.5);
}
#[test]
fn trailing_words_stagger_and_push_from_the_right() {
let durations = TextRevealDurations::default();
assert!((word_push(25.0, &durations, 0) - 0.0).abs() < 1e-6);
assert!(word_push(25.0, &durations, 1) <= word_push(25.0, &durations, 0));
assert!((word_push(1000.0, &durations, 1) - 1.0).abs() < 1e-5);
let start = word_push_offset(25.0, &durations, 0, 0.5, 72.0);
assert!((start - 36.0).abs() < 1e-4, "got {start}");
assert!((word_push_offset(1000.0, &durations, 0, 0.5, 72.0)).abs() < 1e-4);
}
#[test]
fn trailing_word_opacity_is_a_tiny_clamped_ramp() {
let durations = TextRevealDurations::default();
assert!((word_opacity(25.0, &durations, 0, 2) - 0.0).abs() < 1e-6);
assert!((word_opacity(26.0, &durations, 0, 2) - 0.5).abs() < 1e-5);
assert!((word_opacity(100.0, &durations, 0, 2) - 1.0).abs() < 1e-5);
assert!(word_opacity(26.0, &durations, 0, 0).is_finite());
assert_eq!(word_start(&durations, 0), 25);
assert_eq!(word_start(&durations, 1), 29);
}
#[test]
fn degenerate_zero_length_beats_do_not_produce_nan() {
let durations = TextRevealDurations {
intro_duration: 0,
hold_duration: 0,
recede_duration: 0,
assemble_duration: 0,
word_duration: 0,
..TextRevealDurations::default()
};
for frame in [0.0, 1.0, 10.0, 100.0] {
assert!(reveal_scale(frame, &durations, 2.3, 1.06).is_finite());
assert!(slide_offset(frame, &durations, 150.0).is_finite());
assert!(lead_opacity(frame, &durations).is_finite());
assert!(word_opacity(frame, &durations, 0, 0).is_finite());
assert!(word_push(frame, &durations, 0).is_finite());
}
}
#[test]
fn component_duration_and_meta_match_the_config() {
let component = TextReveal::default();
assert_eq!(component.duration(), 48);
assert_eq!(component.meta().id, "text-reveal");
assert_eq!(component.background(), Some("#0d1117"));
let long = TextReveal::new(TextRevealConfig {
text: "one two three four five six seven eight nine ten eleven twelve".to_owned(),
..TextRevealConfig::default()
});
assert_eq!(long.duration(), 79);
}
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_default_frame_converts_to_a_renderable_tree() {
let component = TextReveal::default();
let meters = crate::embedded_meters();
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"), "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(),
"frame {local} did not convert: {:?}",
converted.err()
);
}
assert!(saw_transform, "no transform was emitted on any frame");
}
}