use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::{LayoutCache, RenderCtx, text_node_centered};
use crate::motion::{
EASE_BACK_OUT, REFERENCE_VMIN, clamp01, interpolate, map_range, motion_scale, normalised_speed,
};
use crate::text_spec::TextSpec;
#[derive(Debug, Clone, PartialEq)]
pub struct SpringScaleInConfig {
pub text: TextSpec,
pub stagger: usize,
pub scale_from: f32,
pub word_frames: usize,
pub speed: f32,
pub tail: usize,
pub reduced_motion: bool,
}
impl Default for SpringScaleInConfig {
fn default() -> Self {
Self {
text: TextSpec {
text: "Fast. Crisp. Fluid.".to_owned(),
font_size: 72.0,
font_weight: 600,
max_width: 960.0,
line_height: 1.2,
tracking_em: -0.03,
..TextSpec::default()
},
stagger: 3,
scale_from: 0.7,
word_frames: 11,
speed: 1.0,
tail: 40,
reduced_motion: false,
}
}
}
pub fn entry_frames(word_count: usize, word_frames: usize, stagger: usize, speed: f32) -> usize {
if word_count == 0 {
return 0;
}
let span = word_frames + (word_count - 1) * stagger;
((span as f32) / normalised_speed(speed)).ceil().max(0.0) as usize
}
pub fn word_channels(
frame: f32,
index: usize,
config: &SpringScaleInConfig,
unit: f32,
) -> (f32, f32) {
let local = frame * normalised_speed(config.speed) - index as f32 * config.stagger as f32;
let span = config.word_frames.max(1) as f32;
let eased = EASE_BACK_OUT.ease(clamp01(local / span));
let opacity = eased;
let start = interpolate(
motion_scale(config.reduced_motion),
[0.0, 1.0],
[1.0, config.scale_from],
);
let scale = map_range(eased, [0.0, 1.0], [start, 1.0]);
(opacity, scale * unit)
}
#[derive(Debug, Clone, Default)]
pub struct SpringScaleIn {
pub config: SpringScaleInConfig,
layout: LayoutCache,
}
impl SpringScaleIn {
pub fn new(config: SpringScaleInConfig) -> Self {
Self {
config,
layout: LayoutCache::new(),
}
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "spring-scale-in",
name: "Spring Scale In",
summary: "Words pop in one by one with a slight overshoot.",
feature: "spring-scale-in",
}
}
}
impl Component for SpringScaleIn {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
entry_frames(
self.config.text.word_count(),
self.config.word_frames,
self.config.stagger,
self.config.speed,
) + self.config.tail
}
fn background(&self) -> Option<&'static str> {
Some("#0d1117")
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
let spec = &config.text;
let family = ctx.fonts.sans.clone();
let unit = ctx.vmin() / REFERENCE_VMIN;
let layout = self.layout.resolve(
ctx,
&family,
&spec.text,
spec.font_size * unit,
spec.max_width * unit,
spec.line_height,
);
let center_x = ctx.width() / 2.0;
let center_y = ctx.height() / 2.0;
let frame = ctx.frame();
let mut nodes: Vec<Svgr<'_>> = Vec::new();
for (index, word, baseline) in layout.all_words(center_y) {
let (opacity, scale) = word_channels(frame, index, config, unit);
nodes.push(text_node_centered(
&word.text,
center_x + word.x,
baseline,
family.clone(),
spec.font_size * scale,
spec.font_weight,
spec.tracking_em,
spec.color.clone(),
opacity,
));
}
let nodes = Svgr::from(nodes);
fframes::svgr!(<g>{nodes}</g>)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entry_frames_staggers_every_word() {
assert_eq!(entry_frames(0, 11, 3, 1.0), 0);
assert_eq!(entry_frames(1, 11, 3, 1.0), 11);
assert_eq!(entry_frames(3, 11, 3, 1.0), 17);
}
#[test]
fn words_start_small_and_overshoot_before_resting() {
let config = SpringScaleInConfig::default();
let (opacity, scale) = word_channels(0.0, 0, &config, 1.0);
assert!(opacity.abs() < 1e-6);
assert!((scale - config.scale_from).abs() < 1e-6);
let overshoot = (1..=11)
.map(|f| word_channels(f as f32, 0, &config, 1.0).1)
.fold(0.0_f32, f32::max);
assert!(
overshoot > 1.0,
"expected an overshoot, peak was {overshoot}"
);
let (opacity, scale) = word_channels(60.0, 0, &config, 1.0);
assert!((opacity - 1.0).abs() < 1e-6);
assert!((scale - 1.0).abs() < 1e-6);
}
#[test]
fn reduced_motion_keeps_the_words_at_rest_size() {
let config = SpringScaleInConfig {
reduced_motion: true,
..SpringScaleInConfig::default()
};
let (_, scale) = word_channels(5.0, 0, &config, 1.5);
assert!((scale - 1.5).abs() < 1e-6, "got {scale}");
}
#[test]
fn duration_matches_the_documented_formula() {
let component = SpringScaleIn::default();
assert_eq!(component.config.text.word_count(), 3);
assert_eq!(component.duration(), 57);
assert_eq!(component.meta().id, "spring-scale-in");
}
}