use crate::animation::FrameTimer;
use crate::error::DotmaxError;
use crate::grid::BrailleGrid;
use crate::render::TerminalRenderer;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use std::fs::File;
use std::io::{BufReader, BufWriter, Read, Write};
use std::path::Path;
use std::time::Duration;
use tracing::debug;
const MAGIC: &[u8; 4] = b"DMAX";
const VERSION: u8 = 1;
const MIN_FPS: u32 = 1;
const MAX_FPS: u32 = 240;
#[derive(Debug)]
pub struct PrerenderedAnimation {
frames: Vec<BrailleGrid>,
frame_rate: u32,
}
impl PrerenderedAnimation {
#[must_use]
pub fn new(frame_rate: u32) -> Self {
Self {
frames: Vec::new(),
frame_rate: frame_rate.clamp(MIN_FPS, MAX_FPS),
}
}
pub fn add_frame(&mut self, frame: BrailleGrid) -> &mut Self {
self.frames.push(frame);
self
}
#[must_use]
pub fn frame_count(&self) -> usize {
self.frames.len()
}
#[must_use]
pub const fn frame_rate(&self) -> u32 {
self.frame_rate
}
pub fn play(&self, renderer: &mut TerminalRenderer) -> Result<(), DotmaxError> {
if self.frames.is_empty() {
debug!("play() called with empty animation, returning immediately");
return Ok(());
}
debug!(
frame_count = self.frames.len(),
frame_rate = self.frame_rate,
"Starting single playback"
);
let mut timer = FrameTimer::new(self.frame_rate);
for (i, frame) in self.frames.iter().enumerate() {
renderer.render(frame)?;
debug!(frame = i, "Rendered frame");
timer.wait_for_next_frame();
}
debug!("Single playback complete");
Ok(())
}
pub fn play_loop(&self, renderer: &mut TerminalRenderer) -> Result<(), DotmaxError> {
if self.frames.is_empty() {
debug!("play_loop() called with empty animation, returning immediately");
return Ok(());
}
debug!(
frame_count = self.frames.len(),
frame_rate = self.frame_rate,
"Starting looped playback"
);
let mut timer = FrameTimer::new(self.frame_rate);
let mut loop_count: u64 = 0;
'outer: loop {
loop_count += 1;
debug!(loop_iteration = loop_count, "Starting animation loop");
for (i, frame) in self.frames.iter().enumerate() {
if event::poll(Duration::ZERO)? {
if let Event::Key(key) = event::read()? {
if key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
debug!(
loops_completed = loop_count,
frame = i,
"Ctrl+C detected, stopping playback"
);
break 'outer;
}
}
}
renderer.render(frame)?;
timer.wait_for_next_frame();
}
}
debug!(total_loops = loop_count, "Looped playback stopped");
Ok(())
}
pub fn save_to_file(&self, path: &Path) -> Result<(), DotmaxError> {
debug!(path = ?path, frames = self.frames.len(), "Saving animation to file");
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
let (width, height) = self
.frames
.first()
.map_or((0, 0), BrailleGrid::dimensions);
writer.write_all(MAGIC)?;
writer.write_all(&[VERSION])?;
writer.write_all(&self.frame_rate.to_le_bytes())?;
#[allow(clippy::cast_possible_truncation)]
let frame_count = self.frames.len() as u32;
writer.write_all(&frame_count.to_le_bytes())?;
#[allow(clippy::cast_possible_truncation)]
let width_u32 = width as u32;
#[allow(clippy::cast_possible_truncation)]
let height_u32 = height as u32;
writer.write_all(&width_u32.to_le_bytes())?;
writer.write_all(&height_u32.to_le_bytes())?;
for frame in &self.frames {
let data = frame.get_raw_patterns();
writer.write_all(data)?;
}
writer.flush()?;
debug!(path = ?path, "Animation saved successfully");
Ok(())
}
pub fn load_from_file(path: &Path) -> Result<Self, DotmaxError> {
debug!(path = ?path, "Loading animation from file");
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let mut magic = [0u8; 4];
reader.read_exact(&mut magic)?;
if &magic != MAGIC {
return Err(DotmaxError::Terminal(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Invalid magic bytes: expected {MAGIC:?}, got {magic:?}"),
)));
}
let mut version = [0u8; 1];
reader.read_exact(&mut version)?;
let file_version = version[0];
if file_version != VERSION {
return Err(DotmaxError::Terminal(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Unsupported file version: expected {VERSION}, got {file_version}"),
)));
}
let mut frame_rate_bytes = [0u8; 4];
reader.read_exact(&mut frame_rate_bytes)?;
let frame_rate = u32::from_le_bytes(frame_rate_bytes);
let mut frame_count_bytes = [0u8; 4];
reader.read_exact(&mut frame_count_bytes)?;
let frame_count = u32::from_le_bytes(frame_count_bytes);
let mut width_bytes = [0u8; 4];
reader.read_exact(&mut width_bytes)?;
let width = u32::from_le_bytes(width_bytes) as usize;
let mut height_bytes = [0u8; 4];
reader.read_exact(&mut height_bytes)?;
let height = u32::from_le_bytes(height_bytes) as usize;
debug!(
frame_rate = frame_rate,
frame_count = frame_count,
width = width,
height = height,
"Read animation header"
);
let mut frames = Vec::with_capacity(frame_count as usize);
let frame_size = width * height;
for i in 0..frame_count {
let mut data = vec![0u8; frame_size];
reader.read_exact(&mut data)?;
let mut grid = BrailleGrid::new(width, height)?;
grid.set_raw_patterns(&data);
frames.push(grid);
debug!(frame = i, "Loaded frame");
}
debug!(path = ?path, frames = frames.len(), "Animation loaded successfully");
Ok(Self {
frames,
frame_rate: frame_rate.clamp(MIN_FPS, MAX_FPS),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_new_creates_empty_animation() {
let animation = PrerenderedAnimation::new(30);
assert_eq!(animation.frame_count(), 0);
assert_eq!(animation.frame_rate(), 30);
}
#[test]
fn test_new_clamps_fps_below_min() {
let animation = PrerenderedAnimation::new(0);
assert_eq!(animation.frame_rate(), 1);
}
#[test]
fn test_new_clamps_fps_above_max() {
let animation = PrerenderedAnimation::new(1000);
assert_eq!(animation.frame_rate(), 240);
}
#[test]
fn test_new_at_min_boundary() {
let animation = PrerenderedAnimation::new(1);
assert_eq!(animation.frame_rate(), 1);
}
#[test]
fn test_new_at_max_boundary() {
let animation = PrerenderedAnimation::new(240);
assert_eq!(animation.frame_rate(), 240);
}
#[test]
fn test_add_frame_increments_count() {
let mut animation = PrerenderedAnimation::new(30);
assert_eq!(animation.frame_count(), 0);
let grid = BrailleGrid::new(10, 5).unwrap();
animation.add_frame(grid);
assert_eq!(animation.frame_count(), 1);
}
#[test]
fn test_add_frame_chaining_works() {
let mut animation = PrerenderedAnimation::new(30);
animation
.add_frame(BrailleGrid::new(10, 5).unwrap())
.add_frame(BrailleGrid::new(10, 5).unwrap())
.add_frame(BrailleGrid::new(10, 5).unwrap());
assert_eq!(animation.frame_count(), 3);
}
#[test]
fn test_add_frame_accepts_different_sizes() {
let mut animation = PrerenderedAnimation::new(30);
animation
.add_frame(BrailleGrid::new(10, 5).unwrap())
.add_frame(BrailleGrid::new(20, 10).unwrap())
.add_frame(BrailleGrid::new(5, 3).unwrap());
assert_eq!(animation.frame_count(), 3);
}
#[test]
fn test_frame_count_returns_zero_for_empty() {
let animation = PrerenderedAnimation::new(30);
assert_eq!(animation.frame_count(), 0);
}
#[test]
fn test_frame_count_returns_correct_value() {
let mut animation = PrerenderedAnimation::new(30);
for _ in 0..5 {
animation.add_frame(BrailleGrid::new(10, 5).unwrap());
}
assert_eq!(animation.frame_count(), 5);
}
#[test]
fn test_save_load_roundtrip_preserves_data() {
let mut animation = PrerenderedAnimation::new(30);
for i in 0..3 {
let mut grid = BrailleGrid::new(10, 5).unwrap();
grid.set_dot(i * 2, 0).unwrap();
animation.add_frame(grid);
}
let temp_file = NamedTempFile::new().unwrap();
let path = temp_file.path();
animation.save_to_file(path).unwrap();
let loaded = PrerenderedAnimation::load_from_file(path).unwrap();
assert_eq!(loaded.frame_rate(), 30);
assert_eq!(loaded.frame_count(), 3);
}
#[test]
fn test_load_with_invalid_magic_returns_error() {
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(b"BADX").unwrap();
temp_file.write_all(&[1u8]).unwrap(); temp_file.write_all(&30u32.to_le_bytes()).unwrap(); temp_file.write_all(&0u32.to_le_bytes()).unwrap(); temp_file.write_all(&10u32.to_le_bytes()).unwrap(); temp_file.write_all(&5u32.to_le_bytes()).unwrap(); temp_file.flush().unwrap();
let result = PrerenderedAnimation::load_from_file(temp_file.path());
assert!(result.is_err());
}
#[test]
fn test_load_nonexistent_file_returns_error() {
let result = PrerenderedAnimation::load_from_file(Path::new("/nonexistent/path/file.dmax"));
assert!(result.is_err());
}
#[test]
fn test_load_truncated_file_returns_error() {
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(b"DMAX").unwrap();
temp_file.flush().unwrap();
let result = PrerenderedAnimation::load_from_file(temp_file.path());
assert!(result.is_err());
}
#[test]
fn test_save_empty_animation() {
let animation = PrerenderedAnimation::new(60);
let temp_file = NamedTempFile::new().unwrap();
let result = animation.save_to_file(temp_file.path());
assert!(result.is_ok());
let loaded = PrerenderedAnimation::load_from_file(temp_file.path()).unwrap();
assert_eq!(loaded.frame_count(), 0);
assert_eq!(loaded.frame_rate(), 60);
}
#[test]
fn test_save_creates_parent_directories() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("subdir/nested/animation.dmax");
let animation = PrerenderedAnimation::new(30);
let result = animation.save_to_file(&path);
assert!(result.is_ok());
assert!(path.exists());
}
}