use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::{LayoutCache, RenderCtx, text_node_centered};
use crate::motion::{EASE_OUT, clamp01, motion_scale};
use crate::text_spec::TextSpec;
pub const DEFAULT_TAIL: usize = 45;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SlideDirection {
Left,
Right,
Top,
#[default]
Bottom,
}
impl SlideDirection {
pub const fn offset(self) -> (f32, f32) {
match self {
Self::Left => (-1.0, 0.0),
Self::Right => (1.0, 0.0),
Self::Top => (0.0, -1.0),
Self::Bottom => (0.0, 1.0),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SlideInTextConfig {
pub text: TextSpec,
pub direction: SlideDirection,
pub duration: usize,
pub stagger: usize,
pub delay: usize,
pub distance: f32,
pub tail: usize,
pub reduced_motion: bool,
}
impl Default for SlideInTextConfig {
fn default() -> Self {
Self {
text: TextSpec {
text: "Slides in from an edge".to_owned(),
..TextSpec::default()
},
direction: SlideDirection::Bottom,
duration: 30,
stagger: 1,
delay: 0,
distance: 40.0,
tail: DEFAULT_TAIL,
reduced_motion: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SlideChannels {
pub opacity: f32,
pub dx: f32,
pub dy: f32,
}
pub const fn direction_offset(direction: SlideDirection) -> (f32, f32) {
direction.offset()
}
pub fn entry_frames(word_count: usize, duration: usize, stagger: usize) -> usize {
if word_count == 0 {
return 0;
}
duration + (word_count - 1) * stagger
}
pub fn word_progress(
frame: f32,
index: usize,
duration: usize,
stagger: usize,
delay: usize,
) -> f32 {
let relative = (frame - delay as f32).max(0.0) - index as f32 * stagger as f32;
clamp01(relative / duration.max(1) as f32)
}
pub fn word_channels(
t: f32,
direction: SlideDirection,
distance: f32,
reduced_motion: bool,
) -> SlideChannels {
let progress = EASE_OUT.ease(clamp01(t));
let (ox, oy) = direction.offset();
let travel = (1.0 - progress) * distance * motion_scale(reduced_motion);
SlideChannels {
opacity: progress,
dx: ox * travel,
dy: oy * travel,
}
}
#[derive(Debug, Clone, Default)]
pub struct SlideInText {
pub config: SlideInTextConfig,
layout: LayoutCache,
}
impl SlideInText {
pub fn new(config: SlideInTextConfig) -> Self {
Self {
config,
layout: LayoutCache::new(),
}
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "slide-in-text",
name: "Slide In Text",
summary: "Words slide in from one edge and ease into place, staggered.",
feature: "slide-in-text",
}
}
}
impl Component for SlideInText {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
entry_frames(
self.config.text.word_count(),
self.config.duration,
self.config.stagger,
) + self.config.tail
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let spec = &self.config.text;
let family = ctx.fonts.sans.clone();
let layout = self.layout.resolve(
ctx,
&family,
&spec.text,
spec.font_size,
spec.max_width,
spec.line_height,
);
let frame = ctx.frame();
let center_x = ctx.width() / 2.0;
let center_y = ctx.height() / 2.0;
let mut nodes: Vec<Svgr<'_>> = Vec::new();
for (index, word, baseline) in layout.all_words(center_y) {
let channels = word_channels(
word_progress(
frame,
index,
self.config.duration,
self.config.stagger,
self.config.delay,
),
self.config.direction,
self.config.distance,
self.config.reduced_motion,
);
nodes.push(text_node_centered(
&word.text,
center_x + word.x + channels.dx,
baseline + channels.dy,
family.clone(),
spec.font_size,
spec.font_weight,
spec.tracking_em,
spec.color.clone(),
channels.opacity,
));
}
let nodes = Svgr::from(nodes);
fframes::svgr!(<g>{nodes}</g>)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn direction_offsets_match_the_source() {
assert_eq!(direction_offset(SlideDirection::Left), (-1.0, 0.0));
assert_eq!(direction_offset(SlideDirection::Right), (1.0, 0.0));
assert_eq!(direction_offset(SlideDirection::Top), (0.0, -1.0));
assert_eq!(direction_offset(SlideDirection::Bottom), (0.0, 1.0));
assert_eq!(SlideDirection::default(), SlideDirection::Bottom);
}
#[test]
fn progress_staggers_and_clamps() {
assert!((word_progress(0.0, 0, 10, 2, 0) - 0.0).abs() < 1e-6);
assert!((word_progress(2.0, 1, 10, 2, 0) - 0.0).abs() < 1e-6);
assert!((word_progress(4.0, 1, 10, 2, 0) - 0.2).abs() < 1e-6);
assert!((word_progress(100.0, 0, 10, 2, 0) - 1.0).abs() < 1e-6);
assert!((word_progress(1.0, 0, 10, 2, 5) - 0.0).abs() < 1e-6);
}
#[test]
fn channels_slide_along_the_direction_vector() {
let start = word_channels(0.0, SlideDirection::Bottom, 40.0, false);
assert!((start.opacity - 0.0).abs() < 1e-6);
assert!(start.dx.abs() < 1e-6);
assert!((start.dy - 40.0).abs() < 1e-6);
let left = word_channels(0.0, SlideDirection::Left, 40.0, false);
assert!((left.dx + 40.0).abs() < 1e-6);
assert!(left.dy.abs() < 1e-6);
let top = word_channels(0.0, SlideDirection::Top, 40.0, false);
assert!(top.dx.abs() < 1e-6);
assert!((top.dy + 40.0).abs() < 1e-6);
let right = word_channels(0.0, SlideDirection::Right, 40.0, false);
assert!((right.dx - 40.0).abs() < 1e-6);
let end = word_channels(1.0, SlideDirection::Bottom, 40.0, false);
assert!((end.opacity - 1.0).abs() < 1e-6);
assert!(end.dx.abs() < 1e-4);
assert!(end.dy.abs() < 1e-4);
}
#[test]
fn reduced_motion_keeps_the_fade_and_drops_the_travel() {
let mid = word_channels(0.5, SlideDirection::Bottom, 40.0, true);
assert!(mid.dx.abs() < 1e-6);
assert!(mid.dy.abs() < 1e-6);
assert!(mid.opacity > 0.5);
let normal = word_channels(0.5, SlideDirection::Bottom, 40.0, false);
assert!((mid.opacity - normal.opacity).abs() < 1e-6);
}
#[test]
fn distance_scales_the_travel_linearly() {
let far = word_channels(0.3, SlideDirection::Right, 80.0, false);
let near = word_channels(0.3, SlideDirection::Right, 40.0, false);
assert!((far.dx - near.dx * 2.0).abs() < 1e-4);
}
#[test]
fn duration_includes_the_hold_tail() {
let component = SlideInText::default();
assert_eq!(component.config.text.word_count(), 5);
assert_eq!(component.duration(), 79);
assert_eq!(component.duration(), entry_frames(5, 30, 1) + DEFAULT_TAIL);
assert_eq!(component.meta().id, "slide-in-text");
assert_eq!(component.meta().feature, "slide-in-text");
}
#[test]
fn zero_word_text_does_not_underflow() {
let component = SlideInText::new(SlideInTextConfig {
text: TextSpec {
text: " ".to_owned(),
..TextSpec::default()
},
..SlideInTextConfig::default()
});
assert_eq!(component.duration(), DEFAULT_TAIL);
}
}