use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::{LayoutCache, RenderCtx, text_node_centered};
use crate::motion::{EASE_OUT, clamp01, motion_scale};
use crate::text_spec::TextSpec;
#[derive(Debug, Clone, PartialEq)]
pub struct WordByWordConfig {
pub text: TextSpec,
pub duration: usize,
pub stagger: usize,
pub delay: usize,
pub distance: f32,
pub tail: usize,
pub reduced_motion: bool,
}
impl Default for WordByWordConfig {
fn default() -> Self {
Self {
text: TextSpec::default(),
duration: 30,
stagger: 3,
delay: 0,
distance: 20.0,
tail: 72,
reduced_motion: false,
}
}
}
pub fn entry_frames(word_count: usize, duration: usize, stagger: usize) -> usize {
if word_count == 0 {
return 0;
}
duration + (word_count - 1) * stagger
}
pub fn word_progress(
frame: f32,
index: usize,
duration: usize,
stagger: usize,
delay: usize,
) -> f32 {
let relative = (frame - delay as f32).max(0.0) - index as f32 * stagger as f32;
clamp01(relative / duration.max(1) as f32)
}
#[derive(Debug, Clone, Default)]
pub struct WordByWord {
pub config: WordByWordConfig,
layout: LayoutCache,
}
impl WordByWord {
pub fn new(config: WordByWordConfig) -> Self {
Self {
config,
layout: LayoutCache::new(),
}
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "word-by-word",
name: "Word by Word",
summary: "Words rise and fade in one after another, staggered.",
feature: "word-by-word",
}
}
}
impl Component for WordByWord {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
entry_frames(
self.config.text.word_count(),
self.config.duration,
self.config.stagger,
) + self.config.tail
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let spec = &self.config.text;
let family = ctx.fonts.sans.clone();
let layout = self.layout.resolve(
ctx,
&family,
&spec.text,
spec.font_size,
spec.max_width,
spec.line_height,
);
let travel = self.config.distance * motion_scale(self.config.reduced_motion);
let frame = ctx.frame();
let center_x = ctx.width() / 2.0;
let center_y = ctx.height() / 2.0;
let mut nodes: Vec<Svgr<'_>> = Vec::new();
for (index, word, baseline) in layout.all_words(center_y) {
let progress = EASE_OUT.ease(word_progress(
frame,
index,
self.config.duration,
self.config.stagger,
self.config.delay,
));
let y = baseline + (1.0 - progress) * travel;
nodes.push(text_node_centered(
&word.text,
center_x + word.x,
y,
family.clone(),
spec.font_size,
spec.font_weight,
spec.tracking_em,
spec.color.clone(),
progress,
));
}
let nodes = Svgr::from(nodes);
fframes::svgr!(<g>{nodes}</g>)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entry_frames_counts_words() {
assert_eq!(entry_frames(3, 30, 3), 36);
assert_eq!(entry_frames(0, 30, 3), 0);
assert_eq!(entry_frames(1, 10, 5), 10);
}
#[test]
fn progress_staggers_and_clamps() {
assert!((word_progress(0.0, 0, 10, 2, 0) - 0.0).abs() < 1e-6);
assert!((word_progress(2.0, 1, 10, 2, 0) - 0.0).abs() < 1e-6);
assert!((word_progress(4.0, 1, 10, 2, 0) - 0.2).abs() < 1e-6);
assert!((word_progress(100.0, 0, 10, 2, 0) - 1.0).abs() < 1e-6);
assert!((word_progress(1.0, 0, 10, 2, 5) - 0.0).abs() < 1e-6);
}
#[test]
fn duration_includes_the_hold_tail() {
let component = WordByWord::default();
assert_eq!(component.duration(), entry_frames(7, 30, 3) + 72);
assert_eq!(component.duration(), 120);
assert_eq!(component.meta().id, "word-by-word");
}
#[test]
fn zero_word_text_does_not_underflow() {
let component = WordByWord::new(WordByWordConfig {
text: TextSpec {
text: " ".to_owned(),
..TextSpec::default()
},
..WordByWordConfig::default()
});
assert_eq!(component.duration(), 72);
}
}