use fframes::Svgr;
use crate::blur::{
BLUR_GHOST_COPIES, BLUR_GHOST_MIN_PX, BLUR_GHOST_SPREAD_RATIO, blur_ghost_alpha,
};
use crate::core::{Component, ComponentMeta};
use crate::ctx::{RenderCtx, char_centers, text_node_centered};
use crate::motion::{
EASE_MATERIAL_OUT, REFERENCE_VMIN, clamp01, interpolate, motion_scale, normalised_speed,
};
#[derive(Debug, Clone, PartialEq)]
pub struct SoftBlurInConfig {
pub text: String,
pub blur_from: f32,
pub font_size: f32,
pub color: String,
pub font_weight: u16,
pub stagger: usize,
pub char_duration_frames: usize,
pub char_travel_frames: usize,
pub speed: f32,
pub tail: usize,
pub tracking_em: f32,
pub reduced_motion: bool,
pub simulate_blur: bool,
}
impl Default for SoftBlurInConfig {
fn default() -> Self {
Self {
text: "Softly, then sharp.".to_owned(),
blur_from: 12.0,
font_size: 72.0,
color: "#e6edf3".to_owned(),
font_weight: 600,
stagger: 1,
char_duration_frames: 27,
char_travel_frames: 9,
speed: 1.0,
tail: 0,
tracking_em: -0.05,
reduced_motion: false,
simulate_blur: true,
}
}
}
pub fn entry_frames(char_count: usize, duration: usize, stagger: usize, speed: f32) -> usize {
if char_count == 0 {
return 0;
}
let span = duration + (char_count - 1) * stagger;
((span as f32) / normalised_speed(speed)).ceil().max(0.0) as usize
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SoftBlurChannels {
pub opacity: f32,
pub y: f32,
pub blur: f32,
}
pub fn character_channels(
frame: f32,
index: usize,
config: &SoftBlurInConfig,
unit: f32,
) -> SoftBlurChannels {
let local = frame * normalised_speed(config.speed) - index as f32 * config.stagger as f32;
let span = config.char_duration_frames.max(1) as f32;
let eased = EASE_MATERIAL_OUT.ease(clamp01(local / span));
let travel = motion_scale(config.reduced_motion);
SoftBlurChannels {
opacity: eased,
y: interpolate(eased, [0.0, 1.0], [16.0 * unit * travel, 0.0]),
blur: (1.0 - eased) * config.blur_from * unit * travel,
}
}
#[derive(Debug, Clone, Default)]
pub struct SoftBlurIn {
pub config: SoftBlurInConfig,
}
impl SoftBlurIn {
pub fn new(config: SoftBlurInConfig) -> Self {
Self { config }
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "soft-blur-in",
name: "Soft Blur In",
summary: "Characters fade up from soft focus one at a time.",
feature: "soft-blur-in",
}
}
}
impl Component for SoftBlurIn {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
entry_frames(
self.config.text.chars().count(),
self.config.char_duration_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 family = ctx.fonts.sans.clone();
let unit = ctx.vmin() / REFERENCE_VMIN;
let size = config.font_size * unit;
let (centres, _) = char_centers(ctx, &family, &config.text, size);
let center_x = ctx.width() / 2.0;
let baseline = ctx.height() / 2.0 + ctx.baseline_offset(&family, size, size * 1.2);
let frame = ctx.frame();
let mut nodes: Vec<Svgr<'_>> = Vec::new();
for (index, (character, dx)) in config.text.chars().zip(centres).enumerate() {
let channels = character_channels(frame, index, config, unit);
let glyph = character.to_string();
let x = center_x + dx;
let y = baseline + channels.y;
if config.simulate_blur && channels.blur > BLUR_GHOST_MIN_PX {
let spread = channels.blur * BLUR_GHOST_SPREAD_RATIO;
let alpha = blur_ghost_alpha(channels.blur, unit);
for copy in 1..=BLUR_GHOST_COPIES {
let sign = if copy % 2 == 0 { -1.0 } else { 1.0 };
let distance = spread * (copy as f32 / BLUR_GHOST_COPIES as f32) * sign;
nodes.push(text_node_centered(
&glyph,
x,
y + distance,
family.clone(),
size,
config.font_weight,
config.tracking_em,
config.color.clone(),
channels.opacity * alpha,
));
}
}
nodes.push(text_node_centered(
&glyph,
x,
y,
family.clone(),
size,
config.font_weight,
config.tracking_em,
config.color.clone(),
channels.opacity,
));
}
let nodes = Svgr::from(nodes);
fframes::svgr!(<g>{nodes}</g>)
}
}
#[cfg(test)]
#[cfg(test)]
mod tests {
use super::*;
use crate::blur::{
BLUR_GHOST_COPIES, BLUR_GHOST_MAX_ALPHA, BLUR_GHOST_MIN_PX, BLUR_GHOST_REFERENCE_PX,
BLUR_GHOST_SPREAD_RATIO,
};
#[test]
fn the_shared_ghost_tokens_are_the_character_level_set() {
assert!((BLUR_GHOST_REFERENCE_PX - 12.0).abs() < f32::EPSILON);
assert!((BLUR_GHOST_MAX_ALPHA - 0.45).abs() < f32::EPSILON);
assert_eq!(BLUR_GHOST_COPIES, 2);
assert!((BLUR_GHOST_SPREAD_RATIO - 0.5).abs() < f32::EPSILON);
assert!((BLUR_GHOST_MIN_PX - 0.05).abs() < f32::EPSILON);
}
#[test]
fn entry_frames_staggers_every_character() {
assert_eq!(entry_frames(0, 27, 1, 1.0), 0);
assert_eq!(entry_frames(19, 27, 1, 1.0), 45);
}
#[test]
fn channels_resolve_to_sharp_and_resting() {
let config = SoftBlurInConfig::default();
let start = character_channels(0.0, 0, &config, 1.0);
assert!(start.opacity.abs() < 1e-6);
assert!((start.blur - config.blur_from).abs() < 1e-6);
assert!((start.y - 16.0).abs() < 1e-6);
let end = character_channels(60.0, 0, &config, 1.0);
assert!((end.opacity - 1.0).abs() < 1e-6);
assert!(end.blur.abs() < 1e-6);
assert!(end.y.abs() < 1e-6);
}
#[test]
fn softness_and_travel_are_scaled_by_the_canvas() {
let config = SoftBlurInConfig::default();
let channels = character_channels(0.0, 0, &config, 1.5);
assert!((channels.blur - config.blur_from * 1.5).abs() < 1e-5);
assert!((channels.y - 24.0).abs() < 1e-5);
}
#[test]
fn reduced_motion_leaves_only_the_fade() {
let config = SoftBlurInConfig {
reduced_motion: true,
..SoftBlurInConfig::default()
};
let channels = character_channels(6.0, 0, &config, 1.5);
assert!(channels.blur.abs() < 1e-6);
assert!(channels.y.abs() < 1e-6);
assert!(channels.opacity > 0.0 && channels.opacity <= 1.0);
}
#[test]
fn duration_matches_the_documented_formula() {
let component = SoftBlurIn::default();
assert_eq!(component.config.text.chars().count(), 19);
assert_eq!(component.duration(), 45);
assert_eq!(component.meta().id, "soft-blur-in");
}
}