use std::sync::OnceLock;
use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::{RenderCtx, text_node_left};
use crate::motion::{EASE_DEPART, EASE_OUT, FPS, clamp01, interpolate, speed_of};
const EXIT_Y_EM: f32 = -1.29;
const ENTER_Y_EM: f32 = 0.135;
const EXIT_ROTATE_DEG: f32 = 90.0;
const EXIT_SCALE: f32 = 0.98;
const BLUR_EM: f32 = 0.085;
const CARET_WIDTH_EM: f32 = 0.055;
const CARET_HEIGHT_EM: f32 = 0.78;
const CARET_BOTTOM_EM: f32 = 0.04;
const DEFAULT_TAIL: f32 = 12.0;
const BACKDROP: &str = "#0b0b10";
pub fn keystroke_offset(i: usize, jitter: f32) -> f32 {
let i = i as f32;
jitter * ((i * 12.9898).sin() * 0.6 + (i * 4.1414).sin() * 0.4)
}
pub fn frames_per_char(cps: f32, fps: f32) -> f32 {
if cps.is_finite() && cps > 0.0 && fps.is_finite() && fps > 0.0 {
fps / cps
} else {
1.0
}
}
pub fn typed_line(prefix: &str, first_word: &str, suffix: &str) -> String {
format!("{prefix} {first_word} {suffix}")
}
pub fn typing_end_frame(
total_chars: usize,
cps: f32,
type_start: f32,
char_fade: f32,
jitter: f32,
fps: f32,
) -> f32 {
let per_char = frames_per_char(cps, fps);
let last = total_chars.saturating_sub(1);
type_start + (last as f32 + keystroke_offset(last, jitter)) * per_char + char_fade
}
pub fn fit_scale(word_width: f32, slot_width: f32) -> f32 {
if slot_width > 0.0 && word_width > slot_width {
slot_width / word_width
} else {
1.0
}
}
pub fn squash(angle_deg: f32) -> f32 {
angle_deg.to_radians().cos()
}
pub fn exit_blur_px(progress: f32, font_size: f32) -> f32 {
speed_of(EASE_DEPART, progress) * BLUR_EM * font_size
}
#[derive(Debug, Clone, PartialEq)]
pub struct WordFlipDurationOptions {
pub prefix: String,
pub words: Vec<String>,
pub suffix: String,
pub cps: f32,
pub type_start: f32,
pub char_fade: f32,
pub jitter: f32,
pub pause: f32,
pub cycle: f32,
pub fps: f32,
pub tail: f32,
}
impl Default for WordFlipDurationOptions {
fn default() -> Self {
Self {
prefix: "Looking For A".to_owned(),
words: vec![
"Modern".to_owned(),
"Stunning".to_owned(),
"Minimal".to_owned(),
],
suffix: "Portfolio".to_owned(),
cps: 9.0,
type_start: 4.0,
char_fade: 6.0,
jitter: 0.18,
pause: 6.0,
cycle: 35.0,
fps: FPS,
tail: DEFAULT_TAIL,
}
}
}
pub fn get_word_flip_duration(options: &WordFlipDurationOptions) -> usize {
let first_word = options.words.first().map_or("", String::as_str);
let total_chars = typed_line(&options.prefix, first_word, &options.suffix)
.chars()
.count();
let typing_end = typing_end_frame(
total_chars,
options.cps,
options.type_start,
options.char_fade,
options.jitter,
options.fps,
);
let total =
typing_end + options.pause + options.words.len() as f32 * options.cycle + options.tail;
total.ceil().max(1.0) as usize
}
#[derive(Debug, Clone, PartialEq)]
pub struct WordFlipLine<'a> {
pub left: f32,
pub slot_x: f32,
pub slot_end: f32,
pub suffix_x: f32,
pub width: f32,
pub baseline: f32,
pub prefix_len: usize,
pub word_len: usize,
pub advances: &'a [f32],
}
impl WordFlipLine<'_> {
pub fn suffix_start(&self) -> usize {
self.prefix_len + self.word_len + 2
}
pub fn advance_before(&self, index: usize) -> f32 {
self.advances.iter().take(index).sum()
}
pub fn char_x(&self, index: usize) -> f32 {
if index <= self.prefix_len {
self.left + self.advance_before(index)
} else if index < self.suffix_start() {
if index + 1 == self.suffix_start() {
self.slot_end
} else {
self.slot_x
+ (self.advance_before(index) - self.advance_before(self.prefix_len + 1))
}
} else {
self.suffix_x + (self.advance_before(index) - self.advance_before(self.suffix_start()))
}
}
pub fn right(&self) -> f32 {
self.left + self.width
}
}
#[derive(Debug, Clone, PartialEq)]
struct WordFlipMetrics {
advances: Vec<f32>,
word_widths: Vec<f32>,
slot_width: f32,
line_step: f32,
baseline_offset: f32,
}
impl WordFlipMetrics {
fn word_width(&self, index: usize) -> f32 {
self.word_widths.get(index).copied().unwrap_or(0.0)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct WordFlipConfig {
pub prefix: String,
pub words: Vec<String>,
pub suffix: String,
pub cps: f32,
pub type_start: f32,
pub char_fade: f32,
pub jitter: f32,
pub pause: f32,
pub cycle: f32,
pub exit_duration: f32,
pub enter_duration: f32,
pub overlap: f32,
pub caret: bool,
pub font_size: f32,
pub font_weight: u16,
pub tracking_em: f32,
pub color: String,
pub accent_color: String,
}
impl Default for WordFlipConfig {
fn default() -> Self {
let options = WordFlipDurationOptions::default();
Self {
prefix: options.prefix,
words: options.words,
suffix: options.suffix,
cps: options.cps,
type_start: options.type_start,
char_fade: options.char_fade,
jitter: options.jitter,
pause: options.pause,
cycle: options.cycle,
exit_duration: 9.0,
enter_duration: 9.0,
overlap: 3.0,
caret: true,
font_size: 72.0,
font_weight: 600,
tracking_em: -0.03,
color: "#f4f4f8".to_owned(),
accent_color: "#7c5cff".to_owned(),
}
}
}
impl WordFlipConfig {
pub fn first_word(&self) -> &str {
self.words.first().map_or("", String::as_str)
}
pub fn duration_options(&self) -> WordFlipDurationOptions {
WordFlipDurationOptions {
prefix: self.prefix.clone(),
words: self.words.clone(),
suffix: self.suffix.clone(),
cps: self.cps,
type_start: self.type_start,
char_fade: self.char_fade,
jitter: self.jitter,
pause: self.pause,
cycle: self.cycle,
fps: FPS,
tail: DEFAULT_TAIL,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct WordFlip {
pub config: WordFlipConfig,
metrics: OnceLock<WordFlipMetrics>,
}
impl WordFlip {
pub fn new(config: WordFlipConfig) -> Self {
Self {
config,
metrics: OnceLock::new(),
}
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "word-flip",
name: "Word Flip",
summary: "A headline types itself out, then its key word flips through alternatives.",
feature: "word-flip",
}
}
fn measure(&self, ctx: &RenderCtx<'_>, family: &str) -> WordFlipMetrics {
let config = &self.config;
let size = config.font_size;
let track = config.tracking_em * size;
let line = typed_line(&config.prefix, config.first_word(), &config.suffix);
let advances: Vec<f32> = line
.chars()
.map(|ch| ctx.text_width(family, size, &ch.to_string()) + track)
.collect();
let word_widths: Vec<f32> = config
.words
.iter()
.map(|word| ctx.text_width(family, size, word) + track * word.chars().count() as f32)
.collect();
let slot_width = word_widths.iter().copied().fold(0.0_f32, f32::max);
let line_step = ctx
.meter(family)
.map_or(size * 1.2, |meter| meter.line_height(size));
WordFlipMetrics {
advances,
word_widths,
slot_width,
line_step,
baseline_offset: ctx.baseline_offset(family, size, line_step),
}
}
fn typed_char(
&self,
ch: char,
x: f32,
baseline: f32,
family: &str,
opacity: f32,
) -> Svgr<'static> {
text_node_left(
ch.to_string(),
x,
baseline,
family.to_owned(),
self.config.font_size,
self.config.font_weight,
self.config.tracking_em,
self.config.color.clone(),
opacity,
)
}
fn slot_word(&self, text: &str, x: f32, baseline: f32, family: &str) -> Svgr<'static> {
text_node_left(
text.to_owned(),
x,
baseline,
family.to_owned(),
self.config.font_size,
self.config.font_weight,
self.config.tracking_em,
self.config.accent_color.clone(),
1.0,
)
}
}
fn flip_transform(px: f32, py: f32, y: f32, sx: f32, sy: f32) -> String {
format!(
"translate({:.4} {:.4}) scale({:.6} {:.6}) translate({:.4} {:.4})",
px,
py + y,
sx,
sy,
-px,
-py
)
}
impl Component for WordFlip {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
get_word_flip_duration(&self.config.duration_options())
}
fn background(&self) -> Option<&'static str> {
Some(BACKDROP)
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
let family = ctx.sans();
let frame = ctx.frame();
let fps = if ctx.info.fps > 0.0 {
ctx.info.fps
} else {
1.0
};
let metrics = self.metrics.get_or_init(|| self.measure(ctx, &family));
let units: Vec<char> = typed_line(&config.prefix, config.first_word(), &config.suffix)
.chars()
.collect();
let per_char = frames_per_char(config.cps, fps);
let starts: Vec<f32> = (0..units.len())
.map(|i| config.type_start + (i as f32 + keystroke_offset(i, config.jitter)) * per_char)
.collect();
let typing_end = starts.last().copied().unwrap_or(config.type_start) + config.char_fade;
let flip_base = typing_end + config.pause;
let char_fade = config.char_fade.max(1.0);
let word_count = config.words.len();
let flips_started = if frame < flip_base || word_count <= 1 {
0
} else {
let cycles = if config.cycle > 0.0 {
((frame - flip_base) / config.cycle).floor()
} else {
0.0
};
((cycles + 1.0).min((word_count - 1) as f32)).max(0.0) as usize
};
let last_local = if flips_started > 0 {
frame - (flip_base + (flips_started - 1) as f32 * config.cycle)
} else {
-1.0
};
let exit_duration = config.exit_duration.max(1e-3);
let enter_duration = config.enter_duration.max(1e-3);
let enter_window_end = exit_duration - config.overlap + enter_duration;
let transitioning = flips_started > 0 && last_local < enter_window_end;
let current_word = config.words.get(flips_started).map_or("", String::as_str);
let typing = flips_started == 0;
let center_x = ctx.width() / 2.0;
let center_y = ctx.height() / 2.0;
let baseline = center_y - metrics.line_step / 2.0 + metrics.baseline_offset;
let prefix_len = config.prefix.chars().count();
let word_len = config.first_word().chars().count();
let suffix_start = prefix_len + word_len + 2;
let prefix_width: f32 = metrics.advances.iter().take(prefix_len).sum();
let suffix_width: f32 = metrics
.advances
.iter()
.skip(suffix_start.min(metrics.advances.len()))
.sum();
let space_a = metrics.advances.get(prefix_len).copied().unwrap_or(0.0);
let space_b = metrics
.advances
.get(prefix_len + word_len + 1)
.copied()
.unwrap_or(0.0);
let width = prefix_width + space_a + metrics.slot_width + space_b + suffix_width;
let left = center_x - width / 2.0;
let slot_x = left + prefix_width + space_a;
let slot_end = slot_x + metrics.slot_width;
let line = WordFlipLine {
left,
slot_x,
slot_end,
suffix_x: slot_end + space_b,
width,
baseline,
prefix_len,
word_len,
advances: &metrics.advances,
};
let mut nodes: Vec<Svgr<'_>> = Vec::new();
for (index, ch) in units.iter().enumerate() {
let is_word_char = index > prefix_len && index < prefix_len + 1 + word_len;
let is_slot_space = index == prefix_len + word_len + 1;
if is_word_char || is_slot_space || *ch == ' ' {
continue;
}
let opacity = if typing {
interpolate(
frame,
[starts[index], starts[index] + char_fade],
[0.0, 1.0],
)
} else {
1.0
};
nodes.push(self.typed_char(*ch, line.char_x(index), baseline, &family, opacity));
}
if typing {
for offset in 0..word_len {
let index = prefix_len + 1 + offset;
let Some(ch) = units.get(index) else {
continue;
};
if *ch == ' ' {
continue;
}
let opacity = interpolate(
frame,
[starts[index], starts[index] + char_fade],
[0.0, 1.0],
);
nodes.push(self.typed_char(*ch, line.char_x(index), baseline, &family, opacity));
}
} else if transitioning {
if last_local < exit_duration {
let u = clamp01(last_local / exit_duration);
let p = EASE_DEPART.ease(u);
let outgoing_index = flips_started.saturating_sub(1);
let outgoing = config.words.get(outgoing_index).map_or("", String::as_str);
let scale = (1.0 + p * (EXIT_SCALE - 1.0))
* fit_scale(metrics.word_width(outgoing_index), metrics.slot_width);
let opacity = if u < 0.65 {
1.0
} else {
interpolate(u, [0.65, 1.0], [1.0, 0.0])
};
let body = self.slot_word(outgoing, slot_x, baseline, &family);
let transform = flip_transform(
slot_x,
baseline,
p * EXIT_Y_EM * config.font_size,
scale,
scale * squash(p * EXIT_ROTATE_DEG),
);
nodes.push(fframes::svgr!(<g transform={transform} opacity={opacity}>{body}</g>));
}
if last_local >= exit_duration - config.overlap {
let e = EASE_OUT.ease(clamp01(
(last_local - (exit_duration - config.overlap)) / enter_duration,
));
let scale = fit_scale(metrics.word_width(flips_started), metrics.slot_width);
let opacity = interpolate(e, [0.0, 0.45], [0.0, 1.0]);
let body = self.slot_word(current_word, slot_x, baseline, &family);
let transform = flip_transform(
slot_x,
baseline,
(1.0 - e) * ENTER_Y_EM * config.font_size,
scale,
scale * squash((1.0 - e) * -EXIT_ROTATE_DEG),
);
nodes.push(fframes::svgr!(<g transform={transform} opacity={opacity}>{body}</g>));
}
} else {
let scale = fit_scale(metrics.word_width(flips_started), metrics.slot_width);
let body = self.slot_word(current_word, slot_x, baseline, &family);
if (scale - 1.0).abs() < 1e-5 {
nodes.push(body);
} else {
let transform = flip_transform(slot_x, baseline, 0.0, scale, scale);
nodes.push(fframes::svgr!(<g transform={transform}>{body}</g>));
}
}
if config.caret {
let caret_at = starts.iter().filter(|start| frame >= **start).count();
let x = if typing {
line.char_x(caret_at.min(units.len()))
} else {
line.right()
};
let caret_width = (CARET_WIDTH_EM * config.font_size).max(2.0);
let height = CARET_HEIGHT_EM * config.font_size;
let bottom = baseline - CARET_BOTTOM_EM * config.font_size;
let on = (((frame / fps) * 2.0).floor() as i64).rem_euclid(2) == 0;
let fill = config.accent_color.clone();
nodes.push(fframes::svgr!(
<rect x={x - caret_width / 2.0} y={bottom - height} width={caret_width}
height={height} fill={fill} opacity={if on { 1.0 } else { 0.15 }} />
));
}
let nodes = Svgr::from(nodes);
fframes::svgr!(<g>{nodes}</g>)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keystroke_offset_is_deterministic_and_zero_at_the_first_key() {
assert!((keystroke_offset(0, 0.18)).abs() < 1e-9);
assert!((keystroke_offset(29, 0.18) - keystroke_offset(29, 0.18)).abs() < f32::EPSILON);
for i in 0..200 {
assert!(keystroke_offset(i, 0.18).abs() <= 0.18 + 1e-6, "at {i}");
}
assert!((keystroke_offset(17, 0.0)).abs() < 1e-9);
}
#[test]
fn typing_end_frame_matches_the_source_defaults() {
assert!((frames_per_char(9.0, 30.0) - 10.0 / 3.0).abs() < 1e-5);
let end = typing_end_frame(30, 9.0, 4.0, 6.0, 0.18, 30.0);
assert!((end - 106.723_05).abs() < 1e-3, "got {end}");
assert!((typing_end_frame(1, 9.0, 4.0, 6.0, 0.18, 30.0) - 10.0).abs() < 1e-6);
assert!((frames_per_char(0.0, 30.0) - 1.0).abs() < 1e-6);
assert!((frames_per_char(f32::NAN, 30.0) - 1.0).abs() < 1e-6);
assert!(typing_end_frame(30, 0.0, 4.0, 6.0, 0.18, 30.0).is_finite());
}
#[test]
fn duration_matches_the_source_defaults() {
let options = WordFlipDurationOptions::default();
assert_eq!(get_word_flip_duration(&options), 230);
assert_eq!(WordFlip::default().duration(), 230);
}
#[test]
fn duration_counts_the_words_as_cycles() {
let single = get_word_flip_duration(&WordFlipDurationOptions {
words: vec!["Modern".to_owned()],
..WordFlipDurationOptions::default()
});
let four = get_word_flip_duration(&WordFlipDurationOptions {
words: vec![
"Modern".to_owned(),
"Stunning".to_owned(),
"Minimal".to_owned(),
"Flexible".to_owned(),
],
..WordFlipDurationOptions::default()
});
assert_eq!(four - single, 35 * 3);
}
#[test]
fn fit_scale_only_ever_shrinks_a_word() {
assert!((fit_scale(100.0, 200.0) - 1.0).abs() < 1e-6);
assert!((fit_scale(200.0, 100.0) - 0.5).abs() < 1e-6);
assert!((fit_scale(50.0, 0.0) - 1.0).abs() < 1e-6);
}
#[test]
fn squash_is_the_orthographic_rotate_x() {
assert!((squash(0.0) - 1.0).abs() < 1e-6);
assert!(squash(90.0).abs() < 1e-6);
assert!(squash(-90.0).abs() < 1e-6);
let mut previous = 1.0_f32;
for step in 1..=90 {
let value = squash(step as f32);
assert!(value < previous, "not compressing at {step}");
previous = value;
}
}
#[test]
fn exit_blur_keys_to_speed_not_progress() {
assert!(exit_blur_px(0.0, 72.0).abs() < 0.5);
let peak = (0..=100)
.map(|i| exit_blur_px(i as f32 / 100.0, 72.0))
.fold(0.0_f32, f32::max);
assert!((peak - BLUR_EM * 72.0).abs() < 0.5, "peak was {peak}");
}
#[test]
fn line_geometry_places_every_bucket() {
let advances = [10.0_f32; 8];
let line = WordFlipLine {
left: 100.0,
slot_x: 130.0,
slot_end: 180.0,
suffix_x: 190.0,
width: 200.0,
baseline: 540.0,
prefix_len: 2,
word_len: 3,
advances: &advances,
};
assert_eq!(line.suffix_start(), 7);
assert!((line.char_x(0) - 100.0).abs() < 1e-5);
assert!((line.char_x(1) - 110.0).abs() < 1e-5);
assert!((line.char_x(2) - 120.0).abs() < 1e-5);
assert!((line.char_x(3) - 130.0).abs() < 1e-5);
assert!((line.char_x(4) - 140.0).abs() < 1e-5);
assert!((line.char_x(5) - 150.0).abs() < 1e-5);
assert!((line.char_x(6) - 180.0).abs() < 1e-5);
assert!((line.char_x(7) - 190.0).abs() < 1e-5);
assert!((line.right() - 300.0).abs() < 1e-5);
}
#[test]
fn caret_positions_follow_the_typed_line() {
let advances = [10.0_f32; 8];
let line = WordFlipLine {
left: 0.0,
slot_x: 30.0,
slot_end: 80.0,
suffix_x: 90.0,
width: 100.0,
baseline: 0.0,
prefix_len: 2,
word_len: 3,
advances: &advances,
};
assert!((line.char_x(0) - 0.0).abs() < 1e-5);
assert!((line.char_x(3) - 30.0).abs() < 1e-5);
assert!((line.char_x(6) - 80.0).abs() < 1e-5);
assert!((line.char_x(8) - line.right()).abs() < 1e-5);
assert!((line.right() - 100.0).abs() < 1e-5);
}
#[test]
fn duration_options_defaults_match_the_source() {
let options = WordFlipDurationOptions::default();
assert_eq!(options.prefix, "Looking For A");
assert_eq!(
options.words,
vec![
"Modern".to_owned(),
"Stunning".to_owned(),
"Minimal".to_owned()
]
);
assert_eq!(options.suffix, "Portfolio");
assert!((options.cps - 9.0).abs() < 1e-6);
assert!((options.type_start - 4.0).abs() < 1e-6);
assert!((options.char_fade - 6.0).abs() < 1e-6);
assert!((options.jitter - 0.18).abs() < 1e-6);
assert!((options.pause - 6.0).abs() < 1e-6);
assert!((options.cycle - 35.0).abs() < 1e-6);
assert!((options.fps - 30.0).abs() < 1e-6);
assert!((options.tail - 12.0).abs() < 1e-6);
assert_eq!(
typed_line("Looking For A", "Modern", "Portfolio")
.chars()
.count(),
30
);
}
#[test]
fn component_meta_and_background() {
let component = WordFlip::default();
assert_eq!(component.meta().id, "word-flip");
assert_eq!(component.background(), Some("#0b0b10"));
assert_eq!(component.config.first_word(), "Modern");
}
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 = WordFlip::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");
}
}