use std::sync::OnceLock;
use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::{RenderCtx, text_node_left};
use crate::motion::{EASE_OUT, random};
const FONT_SIZE: f32 = 42.0;
const FONT_WEIGHT: u16 = 400;
const LINE_HEIGHT: f32 = 1.5;
const COLOR: &str = "#e6edf3";
const BACKGROUND: &str = "#0d1117";
const CURSOR: &str = "|";
pub const KEYBOARD_CHARS: [char; 67] = [
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's',
't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L',
'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2', '3', '4',
'5', '6', '7', '8', '9', '.', ',', '!', '?', ' ',
];
#[derive(Debug, Clone, PartialEq)]
pub struct TypeWriterConfig {
pub texts: Vec<String>,
pub type_speed: usize,
pub delete_speed: usize,
pub pause_after_type: usize,
pub pause_after_delete: usize,
pub error_rate: f32,
pub error_correct_delay: usize,
pub seed: String,
pub cursor: bool,
pub blink_speed: usize,
pub show_cursor_after_complete: bool,
pub delay: usize,
pub looping: bool,
pub delete_before_next: bool,
pub tail: usize,
}
impl Default for TypeWriterConfig {
fn default() -> Self {
Self {
texts: vec!["Hello, World!\nThis text is typed character by character.".to_owned()],
type_speed: 3,
delete_speed: 2,
pause_after_type: 30,
pause_after_delete: 15,
error_rate: 0.0,
error_correct_delay: 5,
seed: "typewriter".to_owned(),
cursor: true,
blink_speed: 30,
show_cursor_after_complete: true,
delay: 0,
looping: false,
delete_before_next: true,
tail: 30,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimelineEvent {
pub frame: usize,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypeWriterTimeline {
pub events: Vec<TimelineEvent>,
pub total_duration: usize,
}
impl TypeWriterTimeline {
pub fn text_at(&self, relative_frame: f32) -> String {
let limit = relative_frame.max(0.0) as usize;
let mut displayed = String::new();
for event in &self.events {
if event.frame > limit {
break;
}
displayed.clone_from(&event.text);
}
displayed
}
}
fn push(events: &mut Vec<TimelineEvent>, time: usize, current: &[char]) {
events.push(TimelineEvent {
frame: time,
text: current.iter().collect(),
});
}
fn clear_all(
events: &mut Vec<TimelineEvent>,
time: &mut usize,
current: &mut Vec<char>,
delete_speed: usize,
pause_after_delete: usize,
) {
while !current.is_empty() {
let _ = current.pop();
*time += delete_speed;
push(events, *time, current);
}
*time += pause_after_delete;
}
pub fn simulate_type_writer(config: &TypeWriterConfig) -> TypeWriterTimeline {
let mut events = vec![TimelineEvent {
frame: 0,
text: String::new(),
}];
let mut time: usize = 0;
let mut current: Vec<char> = Vec::new();
for (str_index, text) in config.texts.iter().enumerate() {
if str_index > 0 && config.delete_before_next {
clear_all(
&mut events,
&mut time,
&mut current,
config.delete_speed,
config.pause_after_delete,
);
}
for (i, ch) in text.chars().enumerate() {
if random(&format!("{}-{}-{}", config.seed, str_index, i)) < config.error_rate {
let wrong_seed = format!("{}-wrong-{}-{}", config.seed, str_index, i);
let pick = (random(&wrong_seed) * KEYBOARD_CHARS.len() as f32).floor();
let index = (pick.max(0.0) as usize).min(KEYBOARD_CHARS.len() - 1);
current.push(KEYBOARD_CHARS[index]);
time += config.type_speed;
push(&mut events, time, ¤t);
time += config.error_correct_delay;
let _ = current.pop();
push(&mut events, time, ¤t);
time += config.delete_speed;
}
current.push(ch);
time += config.type_speed;
push(&mut events, time, ¤t);
}
time += config.pause_after_type;
}
if config.looping && (config.delete_before_next || config.texts.len() > 1) {
clear_all(
&mut events,
&mut time,
&mut current,
config.delete_speed,
config.pause_after_delete,
);
}
let total_duration = events.last().map_or(0, |event| event.frame);
TypeWriterTimeline {
events,
total_duration,
}
}
pub fn get_type_writer_duration(config: &TypeWriterConfig) -> usize {
simulate_type_writer(config).total_duration
}
fn blink_opacity(clock: f32, blink_speed: usize) -> f32 {
let safe_blink = blink_speed.max(2) as f32;
let within_blink = clock % safe_blink;
let half = safe_blink / 2.0;
let fade = half.min(BLINK_FADE_FRAMES);
if fade <= 0.0 {
return 0.0;
}
let fade_in = EASE_OUT.ease((within_blink / fade).min(1.0));
let fade_out = EASE_OUT.ease(((half - within_blink) / fade).min(1.0));
fade_in.min(fade_out).clamp(0.0, 1.0)
}
const BLINK_FADE_FRAMES: f32 = 2.0;
#[derive(Debug, Clone, Copy, Default)]
struct Metrics {
block_left: f32,
baseline_offset: f32,
}
#[derive(Debug, Clone, Default)]
pub struct TypeWriter {
pub config: TypeWriterConfig,
metrics: OnceLock<Metrics>,
}
impl TypeWriter {
pub fn new(config: TypeWriterConfig) -> Self {
Self {
config,
metrics: OnceLock::new(),
}
}
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "type-writer",
name: "Type Writer",
summary: "Types text character by character, with typos, a cursor and an optional loop.",
feature: "type-writer",
}
}
fn metrics(&self, ctx: &RenderCtx<'_>, family: &str) -> Metrics {
*self.metrics.get_or_init(|| {
let mut block_width = 0.0_f32;
for text in &self.config.texts {
for line in text.split('\n') {
block_width = block_width.max(ctx.text_width(family, FONT_SIZE, line));
}
}
if self.config.cursor {
block_width += ctx.text_width(family, FONT_SIZE, CURSOR);
}
Metrics {
block_left: (ctx.width() - block_width) / 2.0,
baseline_offset: ctx.baseline_offset(family, FONT_SIZE, FONT_SIZE * LINE_HEIGHT),
}
})
}
}
impl Component for TypeWriter {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
(self.config.delay + get_type_writer_duration(&self.config) + self.config.tail).max(1)
}
fn background(&self) -> Option<&'static str> {
Some(BACKGROUND)
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let timeline = simulate_type_writer(&self.config);
let family = ctx.mono();
let clock = (ctx.frame() - self.config.delay as f32).max(0.0);
let total_duration = timeline.total_duration as f32;
let relative_frame = if total_duration > 0.0 {
if self.config.looping {
clock % total_duration
} else {
clock.min(total_duration)
}
} else {
0.0
};
let displayed = timeline.text_at(relative_frame);
let complete = !self.config.looping && relative_frame >= total_duration;
let cursor_visible = !complete || self.config.show_cursor_after_complete;
let cursor_opacity = if cursor_visible {
blink_opacity(clock, self.config.blink_speed)
} else {
0.0
};
let metrics = self.metrics(ctx, &family);
let line_step = FONT_SIZE * LINE_HEIGHT;
let lines: Vec<&str> = displayed.split('\n').collect();
let block_height = line_step * lines.len() as f32;
let first_baseline = ctx.height() / 2.0 - block_height / 2.0 + metrics.baseline_offset;
let mut nodes: Vec<Svgr<'_>> = Vec::with_capacity(lines.len() + 1);
for (index, line) in lines.iter().enumerate() {
nodes.push(text_node_left(
(*line).to_owned(),
metrics.block_left,
first_baseline + index as f32 * line_step,
family.clone(),
FONT_SIZE,
FONT_WEIGHT,
0.0,
COLOR.to_owned(),
1.0,
));
}
if self.config.cursor {
let last = lines.len().saturating_sub(1);
let last_line = lines.get(last).copied().unwrap_or("");
let x = metrics.block_left + ctx.text_width(&family, FONT_SIZE, last_line);
let y = first_baseline + last as f32 * line_step;
nodes.push(text_node_left(
CURSOR.to_owned(),
x,
y,
family,
FONT_SIZE,
FONT_WEIGHT,
0.0,
COLOR.to_owned(),
cursor_opacity,
));
}
let nodes = Svgr::from(nodes);
fframes::svgr!(<g>{nodes}</g>)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config(texts: &[&str]) -> TypeWriterConfig {
TypeWriterConfig {
texts: texts.iter().map(|text| (*text).to_owned()).collect(),
..TypeWriterConfig::default()
}
}
fn frames(timeline: &TypeWriterTimeline) -> Vec<usize> {
timeline.events.iter().map(|event| event.frame).collect()
}
fn texts(timeline: &TypeWriterTimeline) -> Vec<&str> {
timeline
.events
.iter()
.map(|event| event.text.as_str())
.collect()
}
#[test]
fn simulate_types_characters_at_type_speed() {
let timeline = simulate_type_writer(&config(&["ab"]));
assert_eq!(frames(&timeline), vec![0, 3, 6]);
assert_eq!(texts(&timeline), vec!["", "a", "ab"]);
assert_eq!(timeline.total_duration, 6);
}
#[test]
fn non_looping_session_ends_on_the_last_typed_character() {
let timeline = simulate_type_writer(&TypeWriterConfig::default());
assert_eq!(timeline.events.len(), 57);
assert_eq!(timeline.total_duration, 168);
assert_eq!(get_type_writer_duration(&TypeWriterConfig::default()), 168);
assert_eq!(
timeline.text_at(168.0),
TypeWriterConfig::default().texts[0]
);
assert_eq!(timeline.text_at(167.0).chars().count(), 55);
}
#[test]
fn delete_before_next_clears_the_screen_between_strings() {
let timeline = simulate_type_writer(&config(&["a", "b"]));
assert_eq!(frames(&timeline), vec![0, 3, 35, 53]);
assert_eq!(texts(&timeline), vec!["", "a", "", "b"]);
assert_eq!(timeline.total_duration, 53);
}
#[test]
fn append_mode_keeps_the_previous_string() {
let timeline = simulate_type_writer(&TypeWriterConfig {
delete_before_next: false,
..config(&["a", "b"])
});
assert_eq!(frames(&timeline), vec![0, 3, 36]);
assert_eq!(texts(&timeline), vec!["", "a", "ab"]);
assert_eq!(timeline.total_duration, 36);
}
#[test]
fn looping_clears_the_screen_for_the_next_cycle() {
let timeline = simulate_type_writer(&TypeWriterConfig {
looping: true,
..config(&["ab"])
});
assert_eq!(frames(&timeline), vec![0, 3, 6, 38, 40]);
assert_eq!(texts(&timeline), vec!["", "a", "ab", "a", ""]);
assert_eq!(timeline.total_duration, 40);
}
#[test]
fn looping_in_append_mode_with_one_string_does_not_clear() {
let timeline = simulate_type_writer(&TypeWriterConfig {
looping: true,
delete_before_next: false,
..config(&["ab"])
});
assert_eq!(frames(&timeline), vec![0, 3, 6]);
assert_eq!(timeline.total_duration, 6);
}
#[test]
fn looping_in_append_mode_with_several_strings_clears() {
let timeline = simulate_type_writer(&TypeWriterConfig {
looping: true,
delete_before_next: false,
..config(&["a", "b"])
});
assert_eq!(frames(&timeline), vec![0, 3, 36, 68, 70]);
assert_eq!(texts(&timeline), vec!["", "a", "ab", "a", ""]);
assert_eq!(timeline.total_duration, 70);
}
#[test]
fn error_path_is_deterministic_and_keeps_the_timeline_sorted() {
let typo_config = TypeWriterConfig {
error_rate: 0.5,
seed: "typo-seed".to_owned(),
..config(&["hello world"])
};
let first = simulate_type_writer(&typo_config);
let second = simulate_type_writer(&typo_config);
assert_eq!(first, second);
let typos = (0..11)
.filter(|i| random(&format!("typo-seed-0-{i}")) < 0.5)
.count();
assert!(typos > 0, "this seed must produce at least one typo");
assert_eq!(first.total_duration, 33 + typos * 10);
assert_eq!(first.events.len(), 12 + typos * 2);
assert!(
first
.events
.windows(2)
.all(|pair| pair[0].frame <= pair[1].frame),
"events must stay in non-decreasing frame order"
);
for event in &first.events {
for ch in event.text.chars() {
let known = KEYBOARD_CHARS.contains(&ch) || "hello world".contains(ch);
assert!(known, "unexpected character {ch:?}");
}
}
}
#[test]
fn a_full_error_rate_terminates_and_always_corrects_itself() {
let noisy = simulate_type_writer(&TypeWriterConfig {
error_rate: 2.0,
..config(&["abc"])
});
let clean = simulate_type_writer(&config(&["abc"]));
assert_eq!(clean.total_duration, 9);
assert_eq!(noisy.total_duration, 39);
assert_eq!(noisy.events.len(), 1 + 3 * 3);
assert_eq!(
noisy.events.last().map(|event| event.text.as_str()),
Some("abc")
);
assert_eq!(KEYBOARD_CHARS.len(), 67);
}
#[test]
fn unicode_is_counted_in_characters_not_bytes() {
let timeline = simulate_type_writer(&config(&["éü"]));
assert_eq!(timeline.total_duration, 6);
assert_eq!(texts(&timeline), vec!["", "é", "éü"]);
}
#[test]
fn text_at_returns_the_last_event_that_has_passed() {
let timeline = simulate_type_writer(&config(&["ab"]));
assert_eq!(timeline.text_at(0.0), "");
assert_eq!(timeline.text_at(2.9), "");
assert_eq!(timeline.text_at(3.0), "a");
assert_eq!(timeline.text_at(5.75), "a");
assert_eq!(timeline.text_at(6.0), "ab");
assert_eq!(timeline.text_at(10_000.0), "ab");
assert_eq!(timeline.text_at(-5.0), "");
assert_eq!(timeline.text_at(f32::NAN), "");
}
#[test]
fn blink_ramps_and_stays_in_range() {
assert!((blink_opacity(0.0, 30) - 0.0).abs() < 1e-6);
assert!(blink_opacity(1.0, 30) > 0.0);
assert!((blink_opacity(2.0, 30) - 1.0).abs() < 1e-6);
assert!((blink_opacity(10.0, 30) - 1.0).abs() < 1e-6);
assert!((blink_opacity(15.0, 30) - 0.0).abs() < 1e-6);
assert!((blink_opacity(29.0, 30) - 0.0).abs() < 1e-6);
assert!(blink_opacity(0.0, 0).is_finite());
assert!((blink_opacity(0.0, 1) - 0.0).abs() < 1e-6);
assert!(blink_opacity(0.5, 2) > 0.0);
assert!((blink_opacity(1.0, 2) - 0.0).abs() < 1e-6);
for frame in 0..120 {
let opacity = blink_opacity(frame as f32, 30);
assert!(
(0.0..=1.0).contains(&opacity),
"opacity {opacity} out of range"
);
}
}
#[test]
fn duration_covers_delay_session_and_tail() {
let component = TypeWriter::default();
assert_eq!(component.duration(), 198);
assert_eq!(component.meta().id, "type-writer");
assert_eq!(component.background(), Some("#0d1117"));
let shifted = TypeWriter::new(TypeWriterConfig {
delay: 10,
tail: 5,
..config(&["ab"])
});
assert_eq!(shifted.duration(), 10 + 6 + 5);
}
#[test]
fn empty_texts_still_report_a_positive_duration() {
let component = TypeWriter::new(TypeWriterConfig {
texts: Vec::new(),
tail: 0,
..TypeWriterConfig::default()
});
assert_eq!(get_type_writer_duration(&component.config), 0);
assert_eq!(component.duration(), 1);
}
#[test]
fn show_renders_frames_without_panicking() {
let component = TypeWriter::default();
let meters = crate::embedded_meters();
let fonts = crate::core::Fonts::default();
let mut finished = String::new();
for local in [0.0_f32, 1.0, 3.0, 84.0, 167.0, 168.0, 197.0] {
let mut frame = fframes::Frame::new(local as usize, local as usize, 30);
let info = crate::ctx::ComponentFrame {
local,
global: local as usize,
fps: 30.0,
duration: component.duration(),
width: crate::core::CANVAS_WIDTH as f32,
height: crate::core::CANVAS_HEIGHT as f32,
};
let mut ctx = RenderCtx::new(&mut frame, fonts.clone(), &meters, info);
let svg = component.show(&mut ctx).to_string();
assert!(!svg.is_empty(), "no SVG at frame {local}");
if local == 197.0 {
finished = svg;
}
}
assert!(finished.contains("Hello, World!"), "{finished}");
assert!(
finished.contains("This text is typed character by character."),
"{finished}"
);
}
}