use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::{RenderCtx, text_node_centered};
use crate::motion::{REFERENCE_VMIN, clamp01, ease_out_cubic, interpolate, normalised_speed};
#[derive(Debug, Clone, PartialEq)]
pub struct SqueezeInConfig {
pub text: String,
pub font_size: f32,
pub color: String,
pub font_weight: u16,
pub stagger: usize,
pub squeeze: f32,
pub squeeze_frames: usize,
pub speed: f32,
pub tail: usize,
pub tracking_em: f32,
}
impl Default for SqueezeInConfig {
fn default() -> Self {
Self {
text: "SQUEEZE IN".to_owned(),
font_size: 72.0,
color: "#e6edf3".to_owned(),
font_weight: 400,
stagger: 3,
squeeze: 0.03,
squeeze_frames: 16,
speed: 1.0,
tail: 45,
tracking_em: -0.02,
}
}
}
pub fn reveal_end(char_count: usize, stagger: usize) -> usize {
char_count * stagger
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SqueezeChannels {
pub gap: f32,
pub anchor_x: f32,
pub wipe_percent: f32,
}
pub fn squeeze_channels(
frame: f32,
config: &SqueezeInConfig,
unit: f32,
char_count: usize,
) -> SqueezeChannels {
let local = frame * normalised_speed(config.speed);
let last_reveal = reveal_end(char_count, config.stagger) as f32;
let squeeze_p = ease_out_cubic(clamp01(
(local - last_reveal) / config.squeeze_frames.max(1) as f32,
));
let gap = config.squeeze * config.font_size * unit * (1.0 - squeeze_p);
SqueezeChannels {
gap,
anchor_x: -(gap * char_count.saturating_sub(1) as f32) / 2.0,
wipe_percent: interpolate(local, [0.0, last_reveal.max(1.0)], [100.0, 0.0]),
}
}
#[derive(Debug, Clone, Default)]
pub struct SqueezeIn {
pub config: SqueezeInConfig,
}
impl SqueezeIn {
pub fn new(config: SqueezeInConfig) -> Self {
Self { config }
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "squeeze-in",
name: "Squeeze In",
summary: "A line wipes in character by character, then closes up tight.",
feature: "squeeze-in",
}
}
}
impl Component for SqueezeIn {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
let count = self.config.text.chars().count();
let frames = reveal_end(count, self.config.stagger) + self.config.squeeze_frames;
(frames as f32 / normalised_speed(self.config.speed))
.ceil()
.max(0.0) as usize
+ self.config.tail
}
fn background(&self) -> Option<&'static str> {
Some("#0d1117")
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
let family = ctx.fonts.sans.clone();
let unit = ctx.vmin() / REFERENCE_VMIN;
let size = config.font_size * unit;
let chars: Vec<char> = config.text.chars().collect();
if chars.is_empty() {
return Svgr::empty();
}
let channels = squeeze_channels(ctx.frame(), config, unit, chars.len());
let widths = advances(ctx, &family, size, &chars);
let total: f32 = widths.iter().sum();
let recentre = if chars.len() > 1 {
channels.gap * (chars.len() - 1) as f32 / 2.0
} else {
0.0
};
let mut x = -total / 2.0 - recentre;
let center_x = ctx.width() / 2.0;
let baseline = ctx.height() / 2.0 + ctx.baseline_offset(&family, size, size * 1.1);
let clip_id = "squeeze-in-clip".to_owned();
let clip_left = center_x + x;
let clip_width = ((total + channels.gap * (chars.len() - 1) as f32)
* (1.0 - channels.wipe_percent / 100.0))
.max(1.0);
let mut nodes: Vec<Svgr<'_>> = Vec::new();
for (index, character) in chars.iter().enumerate() {
nodes.push(text_node_centered(
&character.to_string(),
center_x + x + widths[index] / 2.0,
baseline,
family.clone(),
size,
config.font_weight,
config.tracking_em,
config.color.clone(),
1.0,
));
x += widths[index] + channels.gap;
}
let nodes = Svgr::from(nodes);
fframes::svgr!(
<g>
<defs>
<clipPath id={clip_id.clone()}>
<rect x={clip_left} y="0" width={clip_width}
height={ctx.height()} />
</clipPath>
</defs>
<g clip-path={format!("url(#{clip_id})")}>{nodes}</g>
</g>
)
}
}
fn advances(ctx: &RenderCtx<'_>, family: &str, size: f32, chars: &[char]) -> Vec<f32> {
chars
.iter()
.map(|character| {
let mut buffer = [0_u8; 4];
ctx.text_width(family, size, character.encode_utf8(&mut buffer))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_wipe_reveals_one_character_per_stagger() {
let config = SqueezeInConfig::default();
assert_eq!(reveal_end(10, 3), 30);
assert!((squeeze_channels(0.0, &config, 1.0, 10).wipe_percent - 100.0).abs() < 1e-6);
assert!(squeeze_channels(30.0, &config, 1.0, 10).wipe_percent.abs() < 1e-6);
}
#[test]
fn the_squeeze_only_starts_after_the_last_reveal() {
let config = SqueezeInConfig::default();
let open = squeeze_channels(30.0, &config, 1.0, 10);
assert!((open.gap - config.squeeze * config.font_size).abs() < 1e-6);
let expected = -(open.gap * 9.0) / 2.0;
assert!(
(open.anchor_x - expected).abs() < 1e-4,
"got {}",
open.anchor_x
);
let closed = squeeze_channels(46.0, &config, 1.0, 10);
assert!(closed.gap.abs() < 1e-6, "gap was {}", closed.gap);
assert!(closed.anchor_x.abs() < 1e-6, "a closed run needs no shift");
}
#[test]
fn the_gap_scales_with_the_canvas() {
let config = SqueezeInConfig::default();
let wide = squeeze_channels(0.0, &config, 1.5, 10);
let tall = squeeze_channels(0.0, &config, 1.0, 10);
assert!((wide.gap / tall.gap - 1.5).abs() < 1e-5);
}
#[test]
fn duration_counts_the_whole_run() {
let component = SqueezeIn::default();
assert_eq!(component.config.text.chars().count(), 10);
assert_eq!(component.duration(), 91);
assert_eq!(component.meta().id, "squeeze-in");
}
#[test]
fn empty_text_is_just_the_squeeze_window() {
let component = SqueezeIn::new(SqueezeInConfig {
text: String::new(),
..SqueezeInConfig::default()
});
assert_eq!(component.duration(), 16 + 45);
}
}