use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::{RenderCtx, text_node_centered};
use crate::motion::{DUR_QUICK, EASE_OUT, clamp01, random, sample_keyframes};
pub const GLITCH_CHARS: &str =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?/~`";
#[derive(Debug, Clone, PartialEq)]
pub struct GlitchCycleConfig {
pub texts: Vec<String>,
pub item_duration: usize,
pub glitch: Vec<f32>,
pub delay: usize,
pub font_size: f32,
pub font_weight: u16,
pub color: String,
pub tracking_em: f32,
}
impl Default for GlitchCycleConfig {
fn default() -> Self {
Self {
texts: vec![
"BOOT SEQUENCE".to_owned(),
"CALIBRATING".to_owned(),
"ALL SYSTEMS GO".to_owned(),
"READY".to_owned(),
],
item_duration: 60,
glitch: vec![1.0, 0.0, 0.0, 0.1, 0.0, 0.0, 1.0],
delay: 0,
font_size: 64.0,
font_weight: 700,
color: "#e6edf3".to_owned(),
tracking_em: 0.02,
}
}
}
pub fn relative_frame(frame: f32, delay: usize) -> usize {
(frame - delay as f32).max(0.0).floor() as usize
}
pub fn glitch_cycle_duration(text_count: usize, item_duration: usize, delay: usize) -> usize {
(delay + text_count * item_duration.max(1)).max(1)
}
pub fn text_index(frame: f32, text_count: usize, item_duration: usize, delay: usize) -> usize {
if text_count == 0 {
return 0;
}
(relative_frame(frame, delay) / item_duration.max(1)) % text_count
}
pub fn slot_offset(frame: f32, item_duration: usize, delay: usize) -> usize {
relative_frame(frame, delay) % item_duration.max(1)
}
pub fn slot_progress(frame: f32, item_duration: usize, delay: usize) -> f32 {
slot_offset(frame, item_duration, delay) as f32 / item_duration.max(1) as f32
}
pub fn scramble(text: &str, relative_frame: usize, index: usize, glitch_amount: f32) -> String {
let pool: Vec<char> = GLITCH_CHARS.chars().collect();
let mut out = String::with_capacity(text.len());
for (char_idx, ch) in text.chars().enumerate() {
if ch == ' ' {
out.push(' ');
continue;
}
if random(&format!("glitch-{relative_frame}-{index}-{char_idx}")) < glitch_amount {
let roll = random(&format!("char-{relative_frame}-{index}-{char_idx}"));
let pick =
((roll * pool.len() as f32).floor() as usize).min(pool.len().saturating_sub(1));
out.push(pool.get(pick).copied().unwrap_or(ch));
} else {
out.push(ch);
}
}
out
}
#[derive(Debug, Clone, Default)]
pub struct GlitchCycle {
pub config: GlitchCycleConfig,
}
impl GlitchCycle {
pub fn new(config: GlitchCycleConfig) -> Self {
Self { config }
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "glitch-cycle",
name: "Glitch Cycle",
summary: "Strings cycle through a keyframed character scramble.",
feature: "glitch-cycle",
}
}
}
impl Component for GlitchCycle {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
glitch_cycle_duration(
self.config.texts.len(),
self.config.item_duration,
self.config.delay,
)
}
fn background(&self) -> Option<&'static str> {
Some("#0d1117")
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
if config.texts.is_empty() {
return Svgr::empty();
}
let frame = ctx.frame();
let item_duration = config.item_duration.max(1);
let relative = relative_frame(frame, config.delay);
let index = text_index(frame, config.texts.len(), item_duration, config.delay);
let progress = slot_progress(frame, item_duration, config.delay);
let glitch_amount = sample_keyframes(&config.glitch, progress);
let current = config.texts.get(index).map_or("", String::as_str);
let display = scramble(current, relative, index, glitch_amount);
let opacity = EASE_OUT.ease(clamp01(relative as f32 / DUR_QUICK as f32));
let family = ctx.mono();
let font_size = config.font_size;
let baseline = ctx.height() / 2.0 - font_size / 2.0
+ ctx.baseline_offset(&family, font_size, font_size);
text_node_centered(
&display,
ctx.width() / 2.0,
baseline,
family,
font_size,
config.font_weight,
config.tracking_em,
config.color.clone(),
opacity,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn duration_covers_every_slot() {
assert_eq!(glitch_cycle_duration(4, 60, 0), 240);
assert_eq!(glitch_cycle_duration(4, 60, 15), 255);
assert_eq!(glitch_cycle_duration(2, 0, 0), 2);
assert_eq!(glitch_cycle_duration(0, 60, 0), 1);
}
#[test]
fn default_duration_is_four_slots() {
let component = GlitchCycle::default();
assert_eq!(component.duration(), 240);
assert_eq!(component.meta().id, "glitch-cycle");
assert_eq!(component.background(), Some("#0d1117"));
}
#[test]
fn slots_advance_and_wrap() {
assert_eq!(text_index(0.0, 4, 60, 0), 0);
assert_eq!(text_index(59.0, 4, 60, 0), 0);
assert_eq!(text_index(60.0, 4, 60, 0), 1);
assert_eq!(text_index(240.0, 4, 60, 0), 0);
assert_eq!(slot_offset(60.0, 60, 0), 0);
assert_eq!(slot_offset(75.0, 60, 0), 15);
assert!((slot_progress(75.0, 60, 0) - 0.25).abs() < 1e-6);
}
#[test]
fn delay_shifts_and_never_underflows() {
assert_eq!(relative_frame(0.0, 20), 0);
assert_eq!(relative_frame(19.0, 20), 0);
assert_eq!(relative_frame(20.0, 20), 0);
assert_eq!(relative_frame(25.0, 20), 5);
assert_eq!(text_index(5.0, 4, 60, 20), 0);
assert_eq!(text_index(85.0, 4, 60, 20), 1);
}
#[test]
fn empty_texts_do_not_panic() {
assert_eq!(text_index(100.0, 0, 60, 0), 0);
let component = GlitchCycle::new(GlitchCycleConfig {
texts: Vec::new(),
..GlitchCycleConfig::default()
});
assert_eq!(component.duration(), 1);
}
#[test]
fn char_pool_matches_the_source() {
assert_eq!(GLITCH_CHARS.chars().count(), 91);
assert!(GLITCH_CHARS.starts_with("ABC"));
assert!(GLITCH_CHARS.ends_with("~`"));
}
#[test]
fn a_clean_slot_is_untouched() {
assert_eq!(scramble("ALL SYSTEMS GO", 7, 2, 0.0), "ALL SYSTEMS GO");
}
#[test]
fn scramble_is_deterministic_for_a_given_frame() {
let a = scramble("BOOT SEQUENCE", 12, 0, 1.0);
let b = scramble("BOOT SEQUENCE", 12, 0, 1.0);
assert_eq!(a, b);
assert_eq!(a.chars().count(), "BOOT SEQUENCE".chars().count());
assert_eq!(a.chars().nth(4), Some(' '));
assert_ne!(a, "BOOT SEQUENCE");
}
#[test]
fn the_scramble_rerolls_with_the_frame() {
let frames: Vec<String> = (0..8).map(|f| scramble("READY", f, 3, 1.0)).collect();
assert!(frames.iter().any(|f| *f != frames[0]));
}
#[test]
fn glitch_amount_switches_one_character_at_a_time() {
let roll = random("glitch-3-1-0");
assert!(roll > 0.0 && roll < 1.0, "roll {roll}");
assert_eq!(scramble("C", 3, 1, roll - 0.01), "C");
let pool: Vec<char> = GLITCH_CHARS.chars().collect();
let pick =
((random("char-3-1-0") * pool.len() as f32).floor() as usize).min(pool.len() - 1);
assert_eq!(scramble("C", 3, 1, roll + 0.01), pool[pick].to_string());
}
#[test]
fn a_full_scramble_rerolls_every_non_space() {
let text = "C A";
let heavy = scramble(text, 3, 1, 1.0);
assert_eq!(heavy.chars().nth(1), Some(' '));
assert_eq!(heavy.chars().count(), text.chars().count());
}
#[test]
fn intro_opacity_ramps_over_the_quick_duration() {
assert!((EASE_OUT.ease(clamp01(0.0 / DUR_QUICK as f32)) - 0.0).abs() < 1e-6);
assert!((EASE_OUT.ease(clamp01(DUR_QUICK as f32 / DUR_QUICK as f32)) - 1.0).abs() < 1e-6);
}
}