use std::sync::OnceLock;
use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::{RenderCtx, centred_text};
use crate::motion::{CubicBezier, clamp01, random};
pub const CONVERGE_EASING: CubicBezier = CubicBezier::new(0.2, 0.6, 0.35, 1.0);
pub const LETTER_SPACING_EM: f32 = -0.02;
pub const FONT_SIZE_RATIO: f32 = 0.12;
#[derive(Debug, Clone, PartialEq)]
pub struct LinesToTextConfig {
pub lines: Vec<String>,
pub drift_frames: usize,
pub converge_duration: usize,
pub resolve_duration: usize,
pub hold_frames: usize,
pub exit_duration: usize,
pub bars_per_line: usize,
pub start_frame: usize,
}
impl Default for LinesToTextConfig {
fn default() -> Self {
Self {
lines: vec!["LINES".to_owned(), "TO".to_owned(), "TEXT".to_owned()],
drift_frames: 12,
converge_duration: 14,
resolve_duration: 8,
hold_frames: 48,
exit_duration: 18,
bars_per_line: 3,
start_frame: 0,
}
}
}
pub fn lines_to_text_duration(
drift_frames: usize,
converge_duration: usize,
resolve_duration: usize,
hold_frames: usize,
exit_duration: usize,
) -> usize {
(drift_frames + converge_duration + resolve_duration + hold_frames + exit_duration).max(1)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Metrics {
pub font_size: f32,
pub line_step: f32,
pub letter_spacing: f32,
pub bar_height: f32,
pub bar_gap: f32,
}
impl Metrics {
pub fn new(height: f32) -> Self {
let font_size = (height * FONT_SIZE_RATIO).round().max(1.0);
Self {
font_size,
line_step: (font_size * 1.16).round(),
letter_spacing: LETTER_SPACING_EM,
bar_height: (font_size * 0.22).round().max(2.0),
bar_gap: (font_size * 0.05).round(),
}
}
pub fn line_width(&self, measured_advance: f32, chars: usize) -> f32 {
measured_advance + chars as f32 * self.font_size * self.letter_spacing
}
}
pub fn resolve_start(start_frame: usize, drift_frames: usize, converge_duration: usize) -> f32 {
start_frame as f32 + drift_frames as f32 + converge_duration as f32 - 4.0
}
pub fn exit_start(
start_frame: usize,
drift_frames: usize,
converge_duration: usize,
resolve_duration: usize,
hold_frames: usize,
) -> f32 {
resolve_start(start_frame, drift_frames, converge_duration)
+ resolve_duration as f32
+ hold_frames as f32
}
fn eased_progress(frame: f32, start: f32, duration: usize) -> f32 {
CONVERGE_EASING.ease(clamp01((frame - start) / duration.max(1) as f32))
}
#[derive(Debug, Default)]
pub struct LinesToText {
pub config: LinesToTextConfig,
widths: OnceLock<Vec<f32>>,
}
impl LinesToText {
pub fn new(config: LinesToTextConfig) -> Self {
Self {
config,
widths: OnceLock::new(),
}
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "lines-to-text",
name: "Lines to Text",
summary: "Stripe bars drift, converge onto each line, then resolve into type.",
feature: "lines-to-text",
}
}
fn line_widths(
&self,
ctx: &RenderCtx<'_>,
family: &str,
metrics: Metrics,
upper: &[String],
) -> Vec<f32> {
if let Some(cached) = self.widths.get() {
return cached.clone();
}
let measured: Vec<f32> = upper
.iter()
.map(|line| {
let advance = ctx.text_width(family, metrics.font_size, line);
metrics.line_width(advance, line.chars().count())
})
.collect();
let _ = self.widths.set(measured.clone());
measured
}
}
impl Clone for LinesToText {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
widths: OnceLock::new(),
}
}
}
impl Component for LinesToText {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
lines_to_text_duration(
self.config.drift_frames,
self.config.converge_duration,
self.config.resolve_duration,
self.config.hold_frames,
self.config.exit_duration,
)
}
fn background(&self) -> Option<&'static str> {
Some("#e8e9ec")
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
if config.lines.is_empty() {
return Svgr::empty();
}
let bars_per_line = config.bars_per_line.max(1);
let width = ctx.width().max(1.0);
let height = ctx.height();
let metrics = Metrics::new(height);
let family = ctx.sans();
let upper: Vec<String> = config
.lines
.iter()
.map(|line| line.to_uppercase())
.collect();
let widths = self.line_widths(ctx, &family, metrics, &upper);
let line_count = upper.len();
let total_bars = line_count * bars_per_line;
let block_height = (line_count as f32 - 1.0) * metrics.line_step + metrics.font_size;
let centre_y = height / 2.0;
let resolve_at = resolve_start(
config.start_frame,
config.drift_frames,
config.converge_duration,
);
let exit_at = exit_start(
config.start_frame,
config.drift_frames,
config.converge_duration,
config.resolve_duration,
config.hold_frames,
);
let frame = ctx.frame();
let exit_t = eased_progress(frame, exit_at, config.exit_duration);
let mirror = 1.0 - exit_t;
let mut bars: Vec<Svgr<'_>> = Vec::with_capacity(total_bars);
for gi in 0..total_bars {
let line = gi / bars_per_line;
let stripe = gi % bars_per_line;
let line_width = widths.get(line).copied().unwrap_or(0.0);
let field_left = (0.06 + random(&format!("ltt-x-{gi}")) * 0.17) * width;
let field_scale = 0.22 + random(&format!("ltt-w-{gi}")) * 0.58;
let field_y = centre_y
+ (if total_bars == 1 {
0.0
} else {
(gi as f32 / (total_bars - 1) as f32 - 0.5) * block_height * 1.2
})
+ (random(&format!("ltt-j-{gi}")) - 0.5) * metrics.font_size * 0.3;
let line_centre_y =
centre_y + (line as f32 - (line_count as f32 - 1.0) / 2.0) * metrics.line_step;
let bar_y = line_centre_y
+ (stripe as f32 - (bars_per_line as f32 - 1.0) / 2.0)
* (metrics.bar_height + metrics.bar_gap);
let target_scale = line_width / width;
let converge = eased_progress(
frame,
config.start_frame as f32 + config.drift_frames as f32 + gi as f32 * 2.0,
config.converge_duration,
) * mirror;
let drift = (gi as f32 * 2.13 + frame * 0.09).sin() * 9.0 * (1.0 - converge);
let left =
(field_left + drift) * (1.0 - converge) + ((width - line_width) / 2.0) * converge;
let scale = field_scale * (1.0 - converge) + target_scale * converge;
let y = field_y * (1.0 - converge) + bar_y * converge;
let p2 = eased_progress(
frame,
resolve_at + line as f32 * 2.0,
config.resolve_duration,
) * mirror;
let bar_width = width * scale;
let visible = bar_width * (1.0 - p2);
if visible <= 0.0 {
continue;
}
let x = left + bar_width * p2;
bars.push(fframes::svgr!(
<rect x={x} y={y - metrics.bar_height / 2.0} width={visible}
height={metrics.bar_height} fill="#ffffff" />
));
}
let mut defs: Vec<Svgr<'_>> = Vec::with_capacity(line_count);
let mut texts: Vec<Svgr<'_>> = Vec::with_capacity(line_count);
let baseline_offset = ctx.baseline_offset(&family, metrics.font_size, metrics.font_size);
let weight: u16 = 800;
for (index, line) in upper.iter().enumerate() {
let p2 = eased_progress(
frame,
resolve_at + index as f32 * 2.0,
config.resolve_duration,
) * mirror;
let clip_width = width * p2;
if clip_width <= 0.0 {
continue;
}
let id = format!("lines-to-text-clip-{index}");
defs.push(fframes::svgr!(
<clipPath id={id.clone()}>
<rect x="0" y="0" width={clip_width} height={height} />
</clipPath>
));
let y = centre_y + (index as f32 - (line_count as f32 - 1.0) / 2.0) * metrics.line_step
- metrics.font_size / 2.0
+ baseline_offset;
let (content, x) =
centred_text(line, width / 2.0, metrics.font_size, metrics.letter_spacing);
let clip = format!("url(#{id})");
texts.push(fframes::svgr!(
<text x={x} y={y} font-family={family.clone()} font-size={metrics.font_size}
font-weight={weight} letter-spacing={metrics.letter_spacing}
fill="#ffffff" text-anchor="middle" clip-path={clip}>{content}</text>
));
}
let defs = Svgr::from(defs);
let bars = Svgr::from(bars);
let texts = Svgr::from(texts);
fframes::svgr!(
<g>
<defs>{defs}</defs>
{bars}
{texts}
</g>
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn duration_is_the_source_sum() {
assert_eq!(lines_to_text_duration(12, 14, 8, 48, 18), 100);
assert_eq!(lines_to_text_duration(1, 1, 1, 1, 1), 5);
assert_eq!(lines_to_text_duration(0, 0, 0, 0, 0), 1);
}
#[test]
fn default_component_duration_and_meta() {
let component = LinesToText::default();
assert_eq!(component.duration(), 100);
assert_eq!(component.meta().id, "lines-to-text");
assert_eq!(component.background(), Some("#e8e9ec"));
}
#[test]
fn metrics_at_1080p_match_the_source() {
let metrics = Metrics::new(1080.0);
assert!((metrics.font_size - 130.0).abs() < 1e-6);
assert!((metrics.line_step - 151.0).abs() < 1e-6);
assert!((metrics.bar_height - 29.0).abs() < 1e-6);
assert!((metrics.bar_gap - 7.0).abs() < 1e-6);
assert!((metrics.letter_spacing + 0.02).abs() < 1e-6);
let small = Metrics::new(1.0);
assert!(small.bar_height >= 2.0);
assert!(small.font_size >= 1.0);
}
#[test]
fn line_width_applies_the_tracking_correction() {
let metrics = Metrics::new(1080.0);
assert!((metrics.line_width(500.0, 5) - 487.0).abs() < 1e-4);
assert!((metrics.line_width(500.0, 0) - 500.0).abs() < 1e-4);
}
#[test]
fn resolve_and_exit_starts_match_the_source() {
assert!((resolve_start(0, 12, 14) - 22.0).abs() < 1e-6);
assert!((resolve_start(30, 12, 14) - 52.0).abs() < 1e-6);
assert!((exit_start(0, 12, 14, 8, 48) - 78.0).abs() < 1e-6);
assert!((exit_start(30, 12, 14, 8, 48) - 108.0).abs() < 1e-6);
let end = exit_start(0, 12, 14, 8, 48) + 18.0;
let duration = lines_to_text_duration(12, 14, 8, 48, 18) as f32;
assert!((end - (duration - 4.0)).abs() < 1e-6);
}
#[test]
fn eased_progress_clamps_and_uses_the_converge_curve() {
assert!((eased_progress(-100.0, 22.0, 8) - 0.0).abs() < 1e-6);
assert!((eased_progress(22.0, 22.0, 8) - 0.0).abs() < 1e-6);
assert!((eased_progress(30.0, 22.0, 8) - 1.0).abs() < 1e-6);
assert!((eased_progress(1000.0, 22.0, 8) - 1.0).abs() < 1e-6);
let half = eased_progress(26.0, 22.0, 8);
assert!(half > 0.5, "got {half}");
assert!((eased_progress(22.0, 22.0, 0) - 0.0).abs() < 1e-6);
}
#[test]
fn the_exit_mirrors_the_morph() {
let exit_at = exit_start(0, 12, 14, 8, 48);
let at_end = eased_progress(exit_at + 18.0, exit_at, 18);
assert!((at_end - 1.0).abs() < 1e-6);
assert!((1.0 - at_end).abs() < 1e-6);
}
#[test]
fn empty_lines_do_not_panic() {
let component = LinesToText::new(LinesToTextConfig {
lines: Vec::new(),
..LinesToTextConfig::default()
});
assert_eq!(component.duration(), 100);
}
#[test]
fn converged_stripes_centre_on_their_line() {
let metrics = Metrics::new(1080.0);
let centre_y = 1080.0 / 2.0;
let (line_count, bars_per_line) = (3_usize, 3_usize);
let spacing = metrics.bar_height + metrics.bar_gap;
let line_centre_y = centre_y + (1.0 - (line_count as f32 - 1.0) / 2.0) * metrics.line_step;
let middle = line_centre_y + (1.0 - (bars_per_line as f32 - 1.0) / 2.0) * spacing;
let top = line_centre_y + (0.0 - (bars_per_line as f32 - 1.0) / 2.0) * spacing;
assert!((line_centre_y - centre_y).abs() < 1e-4);
assert!((middle - centre_y).abs() < 1e-4);
assert!((middle - top - spacing).abs() < 1e-4);
let (width, line_width) = (1920.0_f32, 500.0_f32);
let left = (width - line_width) / 2.0;
assert!((left - 710.0).abs() < 1e-4);
assert!((left + line_width - (width - left)).abs() < 1e-4);
}
}