use crate::animation::{FrameBuffer, FrameTimer};
use crate::error::DotmaxError;
use crate::grid::BrailleGrid;
use crate::render::TerminalRenderer;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use crossterm::{
cursor::{Hide, Show},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
};
use std::io::{stdout, Write};
use std::time::Duration;
use tracing::{debug, info};
const MIN_FPS: u32 = 1;
const MAX_FPS: u32 = 240;
const DEFAULT_FPS: u32 = 60;
pub struct AnimationLoop<F>
where
F: FnMut(u64, &mut BrailleGrid) -> Result<bool, DotmaxError>,
{
width: usize,
height: usize,
target_fps: u32,
on_frame: F,
}
pub struct AnimationLoopBuilder {
width: usize,
height: usize,
target_fps: u32,
}
impl AnimationLoop<fn(u64, &mut BrailleGrid) -> Result<bool, DotmaxError>> {
#[must_use]
#[allow(clippy::new_ret_no_self)]
pub const fn new(width: usize, height: usize) -> AnimationLoopBuilder {
AnimationLoopBuilder {
width,
height,
target_fps: DEFAULT_FPS,
}
}
}
impl AnimationLoopBuilder {
#[must_use]
pub fn fps(mut self, fps: u32) -> Self {
self.target_fps = fps.clamp(MIN_FPS, MAX_FPS);
self
}
#[must_use]
pub const fn on_frame<F>(self, callback: F) -> AnimationLoop<F>
where
F: FnMut(u64, &mut BrailleGrid) -> Result<bool, DotmaxError>,
{
AnimationLoop {
width: self.width,
height: self.height,
target_fps: self.target_fps,
on_frame: callback,
}
}
}
impl<F> AnimationLoop<F>
where
F: FnMut(u64, &mut BrailleGrid) -> Result<bool, DotmaxError>,
{
pub fn run(&mut self) -> Result<(), DotmaxError> {
info!(
width = self.width,
height = self.height,
target_fps = self.target_fps,
"Starting animation loop"
);
let mut stdout = stdout();
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen, Hide)?;
let result = self.run_inner();
let cleanup_result = Self::cleanup_terminal(&mut stdout);
result.and(cleanup_result)
}
fn run_inner(&mut self) -> Result<(), DotmaxError> {
let mut frame_buffer = FrameBuffer::new(self.width, self.height);
let mut frame_timer = FrameTimer::new(self.target_fps);
let mut renderer = TerminalRenderer::new()?;
let mut frame_num: u64 = 0;
debug!(
width = self.width,
height = self.height,
target_fps = self.target_fps,
"Animation infrastructure initialized"
);
loop {
if event::poll(Duration::ZERO)? {
if let Event::Key(key) = event::read()? {
if key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
info!("Ctrl+C detected, stopping animation gracefully");
break;
}
if key.code == KeyCode::Char('q') {
debug!("'q' pressed, stopping animation");
break;
}
}
}
frame_buffer.get_back_buffer().clear();
let should_continue = (self.on_frame)(frame_num, frame_buffer.get_back_buffer())?;
if !should_continue {
debug!(frame = frame_num, "Callback returned false, stopping");
break;
}
frame_buffer.swap_buffers();
frame_buffer.render(&mut renderer)?;
frame_timer.wait_for_next_frame();
frame_num += 1;
}
info!(
total_frames = frame_num,
actual_fps = frame_timer.actual_fps(),
"Animation completed"
);
Ok(())
}
fn cleanup_terminal(stdout: &mut std::io::Stdout) -> Result<(), DotmaxError> {
execute!(stdout, Show, LeaveAlternateScreen)?;
disable_raw_mode()?;
stdout.flush()?;
debug!("Terminal state restored");
Ok(())
}
#[must_use]
pub const fn width(&self) -> usize {
self.width
}
#[must_use]
pub const fn height(&self) -> usize {
self.height
}
#[must_use]
pub const fn target_fps(&self) -> u32 {
self.target_fps
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder_creates_with_correct_dimensions() {
let anim = AnimationLoop::new(80, 24).on_frame(|_, _| Ok(false));
assert_eq!(anim.width(), 80);
assert_eq!(anim.height(), 24);
}
#[test]
fn test_builder_default_fps_is_60() {
let anim = AnimationLoop::new(80, 24).on_frame(|_, _| Ok(false));
assert_eq!(anim.target_fps(), 60);
}
#[test]
fn test_builder_custom_fps() {
let anim = AnimationLoop::new(80, 24)
.fps(30)
.on_frame(|_, _| Ok(false));
assert_eq!(anim.target_fps(), 30);
}
#[test]
fn test_builder_fps_clamping_below_min() {
let anim = AnimationLoop::new(80, 24)
.fps(0)
.on_frame(|_, _| Ok(false));
assert_eq!(anim.target_fps(), 1, "FPS 0 should be clamped to 1");
}
#[test]
fn test_builder_fps_clamping_above_max() {
let anim = AnimationLoop::new(80, 24)
.fps(1000)
.on_frame(|_, _| Ok(false));
assert_eq!(anim.target_fps(), 240, "FPS 1000 should be clamped to 240");
}
#[test]
fn test_builder_fps_at_min_boundary() {
let anim = AnimationLoop::new(80, 24)
.fps(1)
.on_frame(|_, _| Ok(false));
assert_eq!(anim.target_fps(), 1, "FPS 1 should remain 1");
}
#[test]
fn test_builder_fps_at_max_boundary() {
let anim = AnimationLoop::new(80, 24)
.fps(240)
.on_frame(|_, _| Ok(false));
assert_eq!(anim.target_fps(), 240, "FPS 240 should remain 240");
}
#[test]
fn test_callback_receives_frame_numbers_in_sequence() {
use std::cell::RefCell;
use std::rc::Rc;
let frames_received = Rc::new(RefCell::new(Vec::new()));
let frames_clone = frames_received.clone();
let mut anim = AnimationLoop::new(10, 10).fps(1).on_frame(move |frame, _| {
frames_clone.borrow_mut().push(frame);
Ok(frame < 4) });
let mut buffer = BrailleGrid::new(10, 10).unwrap();
for i in 0..5 {
let _ = (anim.on_frame)(i, &mut buffer);
}
let frames = frames_received.borrow();
assert_eq!(*frames, vec![0, 1, 2, 3, 4]);
}
#[test]
fn test_callback_returning_false_indicates_stop() {
let mut anim = AnimationLoop::new(10, 10).on_frame(|frame, _| Ok(frame < 5));
let mut buffer = BrailleGrid::new(10, 10).unwrap();
for i in 0..5 {
let result = (anim.on_frame)(i, &mut buffer);
assert!(result.unwrap(), "Frame {} should return true", i);
}
let result = (anim.on_frame)(5, &mut buffer);
assert!(!result.unwrap(), "Frame 5 should return false");
}
#[test]
fn test_accessor_methods_return_correct_values() {
let anim = AnimationLoop::new(100, 50)
.fps(120)
.on_frame(|_, _| Ok(false));
assert_eq!(anim.width(), 100);
assert_eq!(anim.height(), 50);
assert_eq!(anim.target_fps(), 120);
}
#[test]
fn test_small_dimensions() {
let anim = AnimationLoop::new(1, 1).on_frame(|_, _| Ok(false));
assert_eq!(anim.width(), 1);
assert_eq!(anim.height(), 1);
}
#[test]
fn test_large_dimensions() {
let anim = AnimationLoop::new(200, 100).on_frame(|_, _| Ok(false));
assert_eq!(anim.width(), 200);
assert_eq!(anim.height(), 100);
}
#[test]
fn test_builder_method_chaining() {
let anim = AnimationLoop::new(80, 24)
.fps(30)
.on_frame(|_, _| Ok(false));
assert_eq!(anim.width(), 80);
assert_eq!(anim.height(), 24);
assert_eq!(anim.target_fps(), 30);
}
}