#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CaptionMode {
RollUp,
PaintOn,
}
#[derive(Clone, Debug)]
pub struct CaptionEvent {
pub text: String,
pub start_ms: i64,
}
impl CaptionEvent {
#[must_use]
pub fn new(text: impl Into<String>, start_ms: i64) -> Self {
Self {
text: text.into(),
start_ms,
}
}
}
#[derive(Clone, Debug)]
pub struct LiveCaptionConfig {
pub mode: CaptionMode,
pub visible_lines: usize,
pub line_duration_ms: i64,
pub chars_per_second: f32,
pub max_chars_per_line: usize,
}
impl Default for LiveCaptionConfig {
fn default() -> Self {
Self {
mode: CaptionMode::RollUp,
visible_lines: 3,
line_duration_ms: 5000,
chars_per_second: 30.0,
max_chars_per_line: 32,
}
}
}
impl LiveCaptionConfig {
#[must_use]
pub fn roll_up(visible_lines: usize) -> Self {
Self {
mode: CaptionMode::RollUp,
visible_lines,
..Self::default()
}
}
#[must_use]
pub fn paint_on(chars_per_second: f32) -> Self {
Self {
mode: CaptionMode::PaintOn,
chars_per_second,
..Self::default()
}
}
}
#[derive(Clone, Debug)]
struct DisplayLine {
text: String,
created_ms: i64,
}
#[derive(Clone, Debug)]
pub struct LiveCaptionDisplay {
config: LiveCaptionConfig,
lines: Vec<DisplayLine>,
paint_buffer: String,
paint_start_ms: i64,
paint_full_text: String,
}
impl LiveCaptionDisplay {
#[must_use]
pub fn new(config: LiveCaptionConfig) -> Self {
Self {
config,
lines: Vec::new(),
paint_buffer: String::new(),
paint_start_ms: 0,
paint_full_text: String::new(),
}
}
#[must_use]
pub fn default_roll_up() -> Self {
Self::new(LiveCaptionConfig::roll_up(3))
}
#[must_use]
pub fn default_paint_on() -> Self {
Self::new(LiveCaptionConfig::paint_on(30.0))
}
#[must_use]
pub fn mode(&self) -> CaptionMode {
self.config.mode
}
#[must_use]
pub fn visible_lines(&self) -> usize {
self.config.visible_lines
}
pub fn feed(&mut self, event: &CaptionEvent) {
match self.config.mode {
CaptionMode::RollUp => {
self.feed_roll_up(event);
}
CaptionMode::PaintOn => {
self.feed_paint_on(event);
}
}
}
fn feed_roll_up(&mut self, event: &CaptionEvent) {
let wrapped = word_wrap(&event.text, self.config.max_chars_per_line);
for line_text in wrapped {
self.lines.push(DisplayLine {
text: line_text,
created_ms: event.start_ms,
});
}
}
fn feed_paint_on(&mut self, event: &CaptionEvent) {
self.paint_full_text = event.text.clone();
self.paint_start_ms = event.start_ms;
self.paint_buffer.clear();
}
#[must_use]
pub fn render(&self, timestamp_ms: i64) -> Vec<String> {
match self.config.mode {
CaptionMode::RollUp => self.render_roll_up(timestamp_ms),
CaptionMode::PaintOn => self.render_paint_on(timestamp_ms),
}
}
fn render_roll_up(&self, timestamp_ms: i64) -> Vec<String> {
let visible: Vec<&DisplayLine> = self
.lines
.iter()
.filter(|line| {
let age = timestamp_ms - line.created_ms;
age >= 0 && age < self.config.line_duration_ms
})
.collect();
let start = if visible.len() > self.config.visible_lines {
visible.len() - self.config.visible_lines
} else {
0
};
visible[start..]
.iter()
.map(|line| line.text.clone())
.collect()
}
fn render_paint_on(&self, timestamp_ms: i64) -> Vec<String> {
if self.paint_full_text.is_empty() {
return Vec::new();
}
let elapsed_ms = timestamp_ms - self.paint_start_ms;
if elapsed_ms < 0 {
return Vec::new();
}
let elapsed_secs = elapsed_ms as f32 / 1000.0;
let chars_to_show = (elapsed_secs * self.config.chars_per_second) as usize;
let chars_to_show = chars_to_show.min(self.paint_full_text.len());
if chars_to_show == 0 {
return Vec::new();
}
let visible_text: String = self.paint_full_text.chars().take(chars_to_show).collect();
word_wrap(&visible_text, self.config.max_chars_per_line)
}
pub fn clear(&mut self) {
self.lines.clear();
self.paint_buffer.clear();
self.paint_full_text.clear();
}
#[must_use]
pub fn total_lines(&self) -> usize {
self.lines.len()
}
#[must_use]
pub fn is_empty(&self, timestamp_ms: i64) -> bool {
self.render(timestamp_ms).is_empty()
}
#[must_use]
pub fn scroll_progress(&self, timestamp_ms: i64) -> f32 {
if self.config.mode != CaptionMode::RollUp || self.lines.is_empty() {
return 0.0;
}
if let Some(last) = self.lines.last() {
let age = timestamp_ms - last.created_ms;
if age < 0 {
return 0.0;
}
let scroll_time = 300.0_f32;
(age as f32 / scroll_time).min(1.0)
} else {
0.0
}
}
#[must_use]
pub fn paint_progress(&self, timestamp_ms: i64) -> f32 {
if self.config.mode != CaptionMode::PaintOn || self.paint_full_text.is_empty() {
return 0.0;
}
let elapsed_ms = timestamp_ms - self.paint_start_ms;
if elapsed_ms < 0 {
return 0.0;
}
let elapsed_secs = elapsed_ms as f32 / 1000.0;
let chars_to_show = elapsed_secs * self.config.chars_per_second;
let total_chars = self.paint_full_text.len() as f32;
if total_chars <= 0.0 {
return 0.0;
}
(chars_to_show / total_chars).min(1.0)
}
}
fn word_wrap(text: &str, max_chars: usize) -> Vec<String> {
if max_chars == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut current_line = String::new();
for word in text.split_whitespace() {
if current_line.is_empty() {
if word.len() > max_chars {
let mut remaining = word;
while remaining.len() > max_chars {
let (chunk, rest) = remaining.split_at(max_chars);
lines.push(chunk.to_string());
remaining = rest;
}
current_line = remaining.to_string();
} else {
current_line = word.to_string();
}
} else if current_line.len() + 1 + word.len() > max_chars {
lines.push(current_line);
current_line = word.to_string();
} else {
current_line.push(' ');
current_line.push_str(word);
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() && !text.is_empty() {
lines.push(String::new());
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_caption_event_basic() {
let event = CaptionEvent::new("Hello", 1000);
assert_eq!(event.text, "Hello");
assert_eq!(event.start_ms, 1000);
}
#[test]
fn test_config_default() {
let config = LiveCaptionConfig::default();
assert_eq!(config.mode, CaptionMode::RollUp);
assert_eq!(config.visible_lines, 3);
}
#[test]
fn test_config_roll_up() {
let config = LiveCaptionConfig::roll_up(4);
assert_eq!(config.mode, CaptionMode::RollUp);
assert_eq!(config.visible_lines, 4);
}
#[test]
fn test_config_paint_on() {
let config = LiveCaptionConfig::paint_on(20.0);
assert_eq!(config.mode, CaptionMode::PaintOn);
assert!((config.chars_per_second - 20.0).abs() < f32::EPSILON);
}
#[test]
fn test_roll_up_basic() {
let mut display = LiveCaptionDisplay::new(LiveCaptionConfig::roll_up(2));
display.feed(&CaptionEvent::new("Line 1", 0));
display.feed(&CaptionEvent::new("Line 2", 100));
display.feed(&CaptionEvent::new("Line 3", 200));
let rendered = display.render(300);
assert_eq!(rendered.len(), 2);
assert_eq!(rendered[0], "Line 2");
assert_eq!(rendered[1], "Line 3");
}
#[test]
fn test_roll_up_expiry() {
let mut display = LiveCaptionDisplay::new(LiveCaptionConfig {
mode: CaptionMode::RollUp,
visible_lines: 3,
line_duration_ms: 1000,
chars_per_second: 30.0,
max_chars_per_line: 32,
});
display.feed(&CaptionEvent::new("Old line", 0));
display.feed(&CaptionEvent::new("New line", 200));
let rendered = display.render(500);
assert_eq!(rendered.len(), 2);
let rendered = display.render(1100);
assert_eq!(rendered.len(), 1);
assert_eq!(rendered[0], "New line");
}
#[test]
fn test_paint_on_basic() {
let mut display = LiveCaptionDisplay::new(LiveCaptionConfig {
mode: CaptionMode::PaintOn,
visible_lines: 3,
line_duration_ms: 5000,
chars_per_second: 10.0, max_chars_per_line: 32,
});
display.feed(&CaptionEvent::new("Hello World", 0));
let rendered = display.render(0);
assert!(rendered.is_empty());
let rendered = display.render(500);
assert_eq!(rendered.len(), 1);
assert_eq!(rendered[0], "Hello");
let rendered = display.render(2000);
assert_eq!(rendered.len(), 1);
assert_eq!(rendered[0], "Hello World");
}
#[test]
fn test_paint_on_progress() {
let mut display = LiveCaptionDisplay::new(LiveCaptionConfig::paint_on(10.0));
display.feed(&CaptionEvent::new("ABCDEFGHIJ", 0));
assert!((display.paint_progress(0) - 0.0).abs() < f32::EPSILON);
assert!((display.paint_progress(500) - 0.5).abs() < 0.01);
assert!((display.paint_progress(1000) - 1.0).abs() < 0.01);
}
#[test]
fn test_display_clear() {
let mut display = LiveCaptionDisplay::default_roll_up();
display.feed(&CaptionEvent::new("Test", 0));
assert!(!display.is_empty(0));
display.clear();
assert!(display.is_empty(0));
}
#[test]
fn test_display_mode() {
let display = LiveCaptionDisplay::default_roll_up();
assert_eq!(display.mode(), CaptionMode::RollUp);
let display = LiveCaptionDisplay::default_paint_on();
assert_eq!(display.mode(), CaptionMode::PaintOn);
}
#[test]
fn test_word_wrap() {
let lines = word_wrap("Hello World", 5);
assert_eq!(lines, vec!["Hello", "World"]);
}
#[test]
fn test_word_wrap_long_word() {
let lines = word_wrap("Supercalifragilistic", 10);
assert_eq!(lines, vec!["Supercalif", "ragilistic"]);
}
#[test]
fn test_word_wrap_fits() {
let lines = word_wrap("Hi", 10);
assert_eq!(lines, vec!["Hi"]);
}
#[test]
fn test_scroll_progress() {
let mut display = LiveCaptionDisplay::default_roll_up();
display.feed(&CaptionEvent::new("Test", 0));
assert!((display.scroll_progress(0) - 0.0).abs() < f32::EPSILON);
assert!((display.scroll_progress(300) - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_visible_lines_config() {
let display = LiveCaptionDisplay::new(LiveCaptionConfig::roll_up(4));
assert_eq!(display.visible_lines(), 4);
}
#[test]
fn test_total_lines() {
let mut display = LiveCaptionDisplay::default_roll_up();
assert_eq!(display.total_lines(), 0);
display.feed(&CaptionEvent::new("A", 0));
display.feed(&CaptionEvent::new("B", 100));
assert_eq!(display.total_lines(), 2);
}
#[test]
fn test_roll_up_before_start() {
let mut display = LiveCaptionDisplay::default_roll_up();
display.feed(&CaptionEvent::new("Future", 1000));
let rendered = display.render(500);
assert!(rendered.is_empty());
}
#[test]
fn test_paint_on_before_start() {
let mut display = LiveCaptionDisplay::default_paint_on();
display.feed(&CaptionEvent::new("Future", 1000));
let rendered = display.render(500);
assert!(rendered.is_empty());
}
#[test]
fn test_paint_on_empty() {
let display = LiveCaptionDisplay::default_paint_on();
let rendered = display.render(1000);
assert!(rendered.is_empty());
}
#[test]
fn test_scroll_progress_paint_on_mode() {
let display = LiveCaptionDisplay::default_paint_on();
assert!((display.scroll_progress(100) - 0.0).abs() < f32::EPSILON);
}
#[test]
fn test_paint_progress_roll_up_mode() {
let display = LiveCaptionDisplay::default_roll_up();
assert!((display.paint_progress(100) - 0.0).abs() < f32::EPSILON);
}
}