#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SmoothOptions {
pub drain_ms: f64,
pub max_char_interval_ms: f64,
pub max_chars_per_frame: usize,
}
impl Default for SmoothOptions {
fn default() -> Self {
Self {
drain_ms: 250.0,
max_char_interval_ms: 5.0,
max_chars_per_frame: usize::MAX,
}
}
}
#[derive(Clone, Debug)]
pub struct SmoothText {
options: SmoothOptions,
target: String,
shown_bytes: usize,
last_update_ms: Option<f64>,
}
impl SmoothText {
pub fn new(text: &str, streaming: bool, options: SmoothOptions) -> Self {
let mut smooth = Self {
options,
target: String::new(),
shown_bytes: 0,
last_update_ms: None,
};
smooth.reset(text, streaming);
smooth
}
fn reset(&mut self, text: &str, streaming: bool) {
self.target = text.to_owned();
self.last_update_ms = None;
self.shown_bytes = if streaming { 0 } else { text.len() };
}
pub fn set_target(&mut self, text: &str, streaming: bool) {
if !text.starts_with(self.text()) {
self.reset(text, streaming);
} else if text.len() != self.target.len() {
self.target.truncate(self.shown_bytes);
self.target.push_str(&text[self.shown_bytes..]);
}
}
pub fn text(&self) -> &str {
&self.target[..self.shown_bytes]
}
pub fn is_animating(&self) -> bool {
self.shown_bytes < self.target.len()
}
pub fn tick(&mut self, now_ms: f64) -> bool {
let remaining = self.target[self.shown_bytes..].chars().count();
if remaining == 0 {
self.last_update_ms = None;
return false;
}
let Some(last) = self.last_update_ms else {
self.last_update_ms = Some(now_ms);
return false;
};
let per_char = self
.options
.max_char_interval_ms
.min(self.options.drain_ms / remaining as f64);
let limit = remaining.min(self.options.max_chars_per_frame);
let mut budget = now_ms - last;
let mut add = 0;
while budget >= per_char && add < limit {
add += 1;
budget -= per_char;
}
if add == limit && limit == self.options.max_chars_per_frame {
budget = 0.0;
}
if add == 0 {
return false;
}
let bytes: usize = self.target[self.shown_bytes..]
.chars()
.take(add)
.map(char::len_utf8)
.sum();
self.shown_bytes += bytes;
self.last_update_ms = Some(now_ms - budget);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run(smooth: &mut SmoothText, from: f64, to: f64) -> f64 {
let mut now = from;
while now <= to {
smooth.tick(now);
now += 16.0;
}
now
}
#[test]
fn a_burst_is_revealed_gradually_and_faster_than_the_slowest_rate() {
let text = "x".repeat(400);
let mut smooth = SmoothText::new("", true, SmoothOptions::default());
smooth.set_target(&text, true);
smooth.tick(0.0);
smooth.tick(16.0);
let first = smooth.text().len();
assert!(
first > 3,
"a big backlog beats 5 ms per character, got {first}"
);
assert!(first < 400, "but it is not shown at once");
let mut now = 32.0;
while smooth.is_animating() && now < 5_000.0 {
smooth.tick(now);
now += 16.0;
}
assert_eq!(smooth.text(), text);
assert!(now < 1_000.0, "drained after {now} ms");
assert!(now > 250.0, "not instant: {now} ms");
}
#[test]
fn a_trickle_is_revealed_at_the_slowest_rate() {
let mut smooth = SmoothText::new("", true, SmoothOptions::default());
smooth.set_target("abcdef", true);
smooth.tick(0.0);
smooth.tick(10.0);
assert_eq!(
smooth.text(),
"ab",
"5 ms per character when the backlog is small"
);
}
#[test]
fn finished_parts_show_at_once_and_unrelated_text_starts_over() {
let mut smooth = SmoothText::new("done already", false, SmoothOptions::default());
assert_eq!(smooth.text(), "done already");
assert!(!smooth.is_animating());
smooth.set_target("a different answer", true);
assert_eq!(smooth.text(), "", "a regeneration starts from nothing");
}
#[test]
fn growing_text_keeps_the_revealed_prefix_and_respects_char_boundaries() {
let mut smooth = SmoothText::new(
"",
true,
SmoothOptions {
max_chars_per_frame: 1,
..Default::default()
},
);
smooth.set_target("雪だ", true);
smooth.tick(0.0);
assert!(smooth.tick(100.0));
assert_eq!(smooth.text(), "雪");
smooth.set_target("雪だるま", true);
assert_eq!(smooth.text(), "雪");
run(&mut smooth, 116.0, 400.0);
assert_eq!(smooth.text(), "雪だるま");
}
}