use std::time::{Duration, Instant};
use unicode_segmentation::UnicodeSegmentation;
use crate::color::{lerp_rgb, Rgb};
#[derive(Debug, Clone, PartialEq)]
pub struct RevealedGrapheme {
pub text: String,
pub color: Rgb,
pub progress: f32,
}
#[derive(Debug, Clone, Copy)]
pub struct RevealOpts {
pub char_interval: Duration,
pub fade_duration: Duration,
pub fade_from: Rgb,
pub fade_to: Rgb,
}
impl RevealOpts {
pub const fn soft_green() -> Self {
Self {
char_interval: Duration::from_millis(45),
fade_duration: Duration::from_millis(320),
fade_from: Rgb(60, 60, 60),
fade_to: Rgb(160, 220, 160),
}
}
}
impl Default for RevealOpts {
fn default() -> Self {
Self::soft_green()
}
}
#[derive(Debug)]
pub struct RevealHandle {
graphemes: Vec<String>,
started_at: Instant,
opts: RevealOpts,
}
impl RevealHandle {
pub fn start_at(text: &str, opts: RevealOpts, now: Instant) -> Self {
let graphemes = text.graphemes(true).map(|g| g.to_string()).collect();
Self {
graphemes,
started_at: now,
opts,
}
}
pub fn start(text: &str, opts: RevealOpts) -> Self {
Self::start_at(text, opts, Instant::now())
}
pub fn total_graphemes(&self) -> usize {
self.graphemes.len()
}
pub fn visible_count(&self, now: Instant) -> usize {
if self.graphemes.is_empty() {
return 0;
}
if self.opts.char_interval.is_zero() {
return self.graphemes.len();
}
let interval_nanos = self.opts.char_interval.as_nanos();
let elapsed_nanos = now.saturating_duration_since(self.started_at).as_nanos();
let by_time = (elapsed_nanos / interval_nanos) as usize + 1;
by_time.min(self.graphemes.len())
}
pub fn is_done(&self, now: Instant) -> bool {
if self.graphemes.is_empty() {
return true;
}
let total_appearance =
self.opts.char_interval * (self.graphemes.len().saturating_sub(1) as u32);
let total_runtime = total_appearance + self.opts.fade_duration;
now.saturating_duration_since(self.started_at) >= total_runtime
}
pub fn snapshot(&self, now: Instant) -> Vec<RevealedGrapheme> {
let visible = self.visible_count(now);
let elapsed = now.saturating_duration_since(self.started_at);
let mut out = Vec::with_capacity(visible);
for i in 0..visible {
let appearance = self.opts.char_interval * (i as u32);
let age = elapsed.saturating_sub(appearance);
let progress = fade_progress(age, self.opts.fade_duration);
let color = lerp_rgb(self.opts.fade_from, self.opts.fade_to, progress);
out.push(RevealedGrapheme {
text: self.graphemes[i].clone(),
color,
progress,
});
}
out
}
}
fn fade_progress(age: Duration, fade: Duration) -> f32 {
if fade.is_zero() {
return 1.0;
}
let raw = age.as_secs_f64() / fade.as_secs_f64();
raw.clamp(0.0, 1.0) as f32
}
#[cfg(test)]
mod tests {
use super::*;
fn opts() -> RevealOpts {
RevealOpts {
char_interval: Duration::from_millis(50),
fade_duration: Duration::from_millis(100),
fade_from: Rgb(0, 0, 0),
fade_to: Rgb(200, 200, 200),
}
}
fn epoch() -> Instant {
Instant::now()
}
#[test]
fn first_grapheme_is_visible_at_t_zero() {
let now = epoch();
let h = RevealHandle::start_at("abc", opts(), now);
let snap = h.snapshot(now);
assert_eq!(snap.len(), 1);
assert_eq!(snap[0].text, "a");
assert_eq!(snap[0].color, Rgb(0, 0, 0));
assert_eq!(snap[0].progress, 0.0);
}
#[test]
fn typewriter_advances_one_grapheme_per_interval() {
let now = epoch();
let h = RevealHandle::start_at("abcd", opts(), now);
assert_eq!(h.visible_count(now), 1);
assert_eq!(h.visible_count(now + Duration::from_millis(50)), 2);
assert_eq!(h.visible_count(now + Duration::from_millis(100)), 3);
assert_eq!(h.visible_count(now + Duration::from_millis(150)), 4);
assert_eq!(h.visible_count(now + Duration::from_millis(500)), 4);
}
#[test]
fn fade_interpolates_over_fade_duration() {
let now = epoch();
let h = RevealHandle::start_at("a", opts(), now);
let snap = h.snapshot(now + Duration::from_millis(50));
assert!((snap[0].progress - 0.5).abs() < 1e-3);
assert_eq!(snap[0].color, Rgb(100, 100, 100));
let snap = h.snapshot(now + Duration::from_millis(100));
assert_eq!(snap[0].progress, 1.0);
assert_eq!(snap[0].color, Rgb(200, 200, 200));
let snap = h.snapshot(now + Duration::from_millis(400));
assert_eq!(snap[0].color, Rgb(200, 200, 200));
}
#[test]
fn later_graphemes_start_their_own_fade() {
let now = epoch();
let h = RevealHandle::start_at("ab", opts(), now);
let snap = h.snapshot(now + Duration::from_millis(50));
assert_eq!(snap.len(), 2);
assert!((snap[0].progress - 0.5).abs() < 1e-3);
assert_eq!(snap[1].progress, 0.0);
}
#[test]
fn iterates_grapheme_clusters_not_chars() {
let composed = "e\u{0301}f"; let now = epoch();
let h = RevealHandle::start_at(composed, opts(), now);
assert_eq!(h.total_graphemes(), 2);
let snap = h.snapshot(now);
assert_eq!(snap.len(), 1);
assert_eq!(snap[0].text, "e\u{0301}");
}
#[test]
fn handles_japanese_text() {
let now = epoch();
let h = RevealHandle::start_at("東京特許許可局", opts(), now);
assert_eq!(h.total_graphemes(), 7);
let snap = h.snapshot(now + Duration::from_millis(150));
assert_eq!(snap.len(), 4);
assert_eq!(snap[0].text, "東");
assert_eq!(snap[3].text, "許");
}
#[test]
fn empty_text_has_no_graphemes() {
let now = epoch();
let h = RevealHandle::start_at("", opts(), now);
assert_eq!(h.total_graphemes(), 0);
assert_eq!(h.visible_count(now), 0);
assert!(h.is_done(now));
assert!(h.snapshot(now).is_empty());
}
#[test]
fn is_done_reports_when_last_grapheme_finishes_fading() {
let now = epoch();
let h = RevealHandle::start_at("abc", opts(), now);
assert!(!h.is_done(now));
assert!(!h.is_done(now + Duration::from_millis(150)));
assert!(!h.is_done(now + Duration::from_millis(199)));
assert!(h.is_done(now + Duration::from_millis(200)));
assert!(h.is_done(now + Duration::from_millis(500)));
}
#[test]
fn zero_fade_produces_instant_full_color() {
let mut o = opts();
o.fade_duration = Duration::ZERO;
let now = epoch();
let h = RevealHandle::start_at("a", o, now);
let snap = h.snapshot(now);
assert_eq!(snap[0].progress, 1.0);
assert_eq!(snap[0].color, o.fade_to);
}
#[test]
fn zero_char_interval_reveals_all_at_once() {
let mut o = opts();
o.char_interval = Duration::ZERO;
let now = epoch();
let h = RevealHandle::start_at("abcd", o, now);
assert_eq!(h.visible_count(now), 4);
let snap = h.snapshot(now + Duration::from_millis(50));
assert_eq!(snap.len(), 4);
for g in &snap {
assert!((g.progress - 0.5).abs() < 1e-3);
}
}
#[test]
fn default_matches_soft_green_preset() {
let a = RevealOpts::default();
let b = RevealOpts::soft_green();
assert_eq!(a.char_interval, b.char_interval);
assert_eq!(a.fade_duration, b.fade_duration);
assert_eq!(a.fade_from, b.fade_from);
assert_eq!(a.fade_to, b.fade_to);
}
}