#[must_use]
pub fn clamp01(t: f32) -> f32 {
if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) }
}
#[must_use]
pub fn reveal_count(local_frame: f32, fps: f32, len: usize, cps: f32) -> usize {
let over = (len as f32 / cps) * fps;
if over <= 0.0 {
return len;
}
let progress = (local_frame / over) * len as f32;
if progress.is_nan() || progress <= 0.0 {
return 0;
}
let count = progress.floor() as i64;
count.clamp(0, len as i64) as usize
}
#[must_use]
pub fn revealed_text(full: &str, count: usize) -> String {
full.chars().take(count).collect()
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Step<'a> {
pub at: f32,
pub state: &'a str,
pub duration: Option<f32>,
}
impl<'a> Step<'a> {
#[must_use]
pub fn new(at: f32, state: &'a str) -> Self {
Self {
at,
state,
duration: None,
}
}
#[must_use]
pub fn with_duration(at: f32, state: &'a str, duration: f32) -> Self {
Self {
at,
state,
duration: Some(duration),
}
}
}
#[must_use]
pub fn current_state<'a>(steps: &[Step<'a>], default: &'a str, frame: f32, speed: f32) -> &'a str {
let effective = frame * speed;
let mut current = default;
let mut best_at = f32::NEG_INFINITY;
for step in steps {
if step.at <= effective && step.at >= best_at {
best_at = step.at;
current = step.state;
}
}
current
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StateTransition<'a> {
pub from: &'a str,
pub to: &'a str,
pub progress: f32,
}
#[must_use]
pub fn state_transition<'a>(
steps: &[Step<'a>],
default: &'a str,
frame: f32,
speed: f32,
default_duration: f32,
) -> StateTransition<'a> {
let effective = frame * speed;
let mut index: Vec<usize> = (0..steps.len()).collect();
index.sort_by(|&a, &b| {
steps[a]
.at
.partial_cmp(&steps[b].at)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.cmp(&b))
});
let started: Vec<&Step<'a>> = index
.into_iter()
.map(|i| &steps[i])
.filter(|step| step.at <= effective)
.collect();
let started: Vec<&Step<'a>> = started
.iter()
.enumerate()
.filter(|&(i, step)| i + 1 == started.len() || started[i + 1].at != step.at)
.map(|(_, step)| *step)
.collect();
let Some(&to) = started.last() else {
return StateTransition {
from: default,
to: default,
progress: 1.0,
};
};
let from = if started.len() >= 2 {
started[started.len() - 2].state
} else {
default
};
let duration = to.duration.unwrap_or(default_duration);
let progress = if duration > 0.0 {
clamp01((effective - to.at) / duration)
} else {
1.0
};
StateTransition {
from,
to: to.state,
progress,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reveal_count_walks_the_string_and_clamps() {
assert_eq!(reveal_count(0.0, 30.0, 10, 20.0), 0);
assert_eq!(reveal_count(15.0, 30.0, 10, 20.0), 10);
assert_eq!(reveal_count(30.0, 30.0, 10, 20.0), 10);
assert_eq!(reveal_count(-5.0, 30.0, 10, 20.0), 0);
assert_eq!(reveal_count(7.5, 30.0, 10, 20.0), 5);
assert_eq!(reveal_count(7.4, 30.0, 10, 20.0), 4);
}
#[test]
fn a_zero_length_string_is_immediately_complete() {
assert_eq!(reveal_count(0.0, 30.0, 0, 20.0), 0);
assert_eq!(reveal_count(10.0, 30.0, 0, 20.0), 0);
}
#[test]
fn revealed_text_takes_a_character_prefix() {
assert_eq!(revealed_text("Ship. Test.", 5), "Ship.");
assert_eq!(revealed_text("Ship. Test.", 0), "");
assert_eq!(revealed_text("Ship. Test.", 99), "Ship. Test.");
}
#[test]
fn current_state_picks_the_last_started_step() {
let steps = [Step::new(0.0, "idle"), Step::new(10.0, "typing")];
assert_eq!(current_state(&steps, "idle", 5.0, 1.0), "idle");
assert_eq!(current_state(&steps, "idle", 10.0, 1.0), "typing");
assert_eq!(current_state(&steps, "idle", 100.0, 1.0), "typing");
}
#[test]
fn current_state_breaks_ties_towards_the_later_entry() {
let steps = [Step::new(4.0, "first"), Step::new(4.0, "second")];
assert_eq!(current_state(&steps, "idle", 4.0, 1.0), "second");
}
#[test]
fn speed_scales_the_frame_before_steps_are_matched() {
let steps = [Step::new(10.0, "typing")];
assert_eq!(current_state(&steps, "idle", 5.0, 2.0), "typing");
assert_eq!(current_state(&steps, "idle", 4.0, 2.0), "idle");
}
#[test]
fn state_transition_reports_from_to_and_progress() {
let steps = [
Step::new(0.0, "idle"),
Step::with_duration(10.0, "typing", 8.0),
];
let before = state_transition(&steps, "idle", 5.0, 1.0, 8.0);
assert_eq!((before.from, before.to), ("idle", "idle"));
assert!(
(before.progress - 0.625).abs() < 1e-6,
"progress {}",
before.progress
);
let mid = state_transition(&steps, "idle", 14.0, 1.0, 8.0);
assert_eq!((mid.from, mid.to), ("idle", "typing"));
assert!(
(mid.progress - 0.5).abs() < 1e-6,
"progress {}",
mid.progress
);
let done = state_transition(&steps, "idle", 30.0, 1.0, 8.0);
assert_eq!((done.from, done.to), ("idle", "typing"));
assert!((done.progress - 1.0).abs() < 1e-6);
}
#[test]
fn a_step_hidden_by_a_tie_never_becomes_the_from_state() {
let steps = [
Step::new(0.0, "idle"),
Step::new(4.0, "skipped"),
Step::new(4.0, "shown"),
];
let transition = state_transition(&steps, "idle", 5.0, 1.0, 8.0);
assert_eq!(transition.to, "shown");
assert_eq!(transition.from, "idle");
}
#[test]
fn a_zero_duration_step_is_immediately_complete() {
let steps = [Step::with_duration(10.0, "cut", 0.0)];
let transition = state_transition(&steps, "idle", 10.0, 1.0, 8.0);
assert!((transition.progress - 1.0).abs() < 1e-6);
}
#[test]
fn clamp01_treats_nan_as_zero() {
assert!((clamp01(f32::NAN) - 0.0).abs() < 1e-6);
assert!((clamp01(-2.0) - 0.0).abs() < 1e-6);
assert!((clamp01(2.0) - 1.0).abs() < 1e-6);
}
}