use std::f64::consts::TAU;
use std::io::Write;
use std::time::{Duration, Instant};
use crossterm::{
queue,
terminal::{Clear, ClearType},
};
#[cfg(not(tarpaulin_include))]
use crossterm::event::{self, Event};
use super::frames::{MascotFrames, queue_frame};
use crate::layout::{Position, Rect, Size, centered_block_origin, position, size, top_two_thirds};
const FLOAT_CYCLE: Duration = Duration::from_millis(6_400);
const DEFAULT_TICK_RATE: Duration = Duration::from_millis(120);
const BOB_AMPLITUDE_ROWS: f64 = 2.0;
const VELOCITY_EPSILON: f64 = 0.000_001;
const BOB_DISTANCE: i16 = BOB_AMPLITUDE_ROWS as i16;
const BASELINE_ROW_OFFSET: i16 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnimationExit {
KeyPress,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BobbingPhase {
Rising,
Falling,
Neutral,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BobbingFrame {
pub phase: BobbingPhase,
pub row_offset: i16,
}
#[derive(Debug, Clone, Copy)]
struct WaveSample {
row_offset: f64,
velocity: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct AnimationOptions {
pub tick_rate: Duration,
}
impl Default for AnimationOptions {
fn default() -> Self {
Self {
tick_rate: DEFAULT_TICK_RATE,
}
}
}
#[cfg(not(tarpaulin_include))]
pub fn run_bobbing_animation<W: Write>(
writer: &mut W,
frames: MascotFrames,
options: AnimationOptions,
) -> std::io::Result<AnimationExit> {
let started = Instant::now();
let mut terminal_size = crate::layout::terminal_size()?;
let mut last_render_was_static = false;
loop {
last_render_was_static = render_cycle_frame(
writer,
frames,
terminal_size,
started.elapsed(),
last_render_was_static,
)?;
writer.flush()?;
if !event::poll(options.tick_rate)? {
continue;
}
match event::read()? {
Event::Key(_) => return Ok(AnimationExit::KeyPress),
Event::Resize(width, height) => {
terminal_size = size(width, height);
last_render_was_static = false;
}
_ => {}
}
}
}
pub fn render_cycle_frame<W: Write>(
writer: &mut W,
frames: MascotFrames,
terminal_size: Size,
elapsed: Duration,
last_render_was_static: bool,
) -> std::io::Result<bool> {
if should_render_static(terminal_size, frames.size()) {
if !last_render_was_static {
queue_static_frame(writer, frames, terminal_size)?;
}
return Ok(true);
}
queue_bobbing_frame(writer, frames, terminal_size, bobbing_frame(elapsed))?;
Ok(false)
}
pub fn bobbing_frame(elapsed: Duration) -> BobbingFrame {
let sample = wave_sample(elapsed);
frame(bobbing_phase(sample), rounded_row_offset(sample))
}
pub fn cycle_duration() -> Duration {
FLOAT_CYCLE
}
fn frame(phase: BobbingPhase, row_offset: i16) -> BobbingFrame {
BobbingFrame { phase, row_offset }
}
fn wave_sample(elapsed: Duration) -> WaveSample {
let angle = cycle_progress(elapsed) * TAU;
WaveSample {
row_offset: BOB_AMPLITUDE_ROWS * angle.cos(),
velocity: -BOB_AMPLITUDE_ROWS * angle.sin(),
}
}
fn cycle_progress(elapsed: Duration) -> f64 {
let cycle = cycle_duration().as_secs_f64();
elapsed.as_secs_f64().rem_euclid(cycle) / cycle
}
fn bobbing_phase(sample: WaveSample) -> BobbingPhase {
if sample.velocity.abs() <= VELOCITY_EPSILON {
return BobbingPhase::Neutral;
}
if sample.velocity < 0.0 {
return BobbingPhase::Rising;
}
if sample.velocity > 0.0 {
return BobbingPhase::Falling;
}
BobbingPhase::Neutral
}
fn rounded_row_offset(sample: WaveSample) -> i16 {
(sample.row_offset.round() as i16).clamp(-BOB_DISTANCE, BOB_DISTANCE) + BASELINE_ROW_OFFSET
}
fn queue_static_frame<W: Write>(
writer: &mut W,
frames: MascotFrames,
terminal_size: Size,
) -> std::io::Result<()> {
queue!(writer, Clear(ClearType::All))?;
frames.queue_centered(writer, terminal_size)
}
fn queue_bobbing_frame<W: Write>(
writer: &mut W,
frames: MascotFrames,
terminal_size: Size,
frame: BobbingFrame,
) -> std::io::Result<()> {
let origin = bobbing_origin(
top_two_thirds(terminal_size),
frames.size(),
frame.row_offset,
);
queue!(writer, Clear(ClearType::All))?;
queue_frame(writer, origin, frames.lines())
}
pub fn bobbing_origin(region: Rect, frame_size: Size, row_offset: i16) -> Position {
let origin = centered_block_origin(region, frame_size);
offset_row(origin, row_offset)
}
fn offset_row(origin: Position, row_offset: i16) -> Position {
if row_offset < 0 {
return position(
origin.column,
origin.row.saturating_sub(row_offset.unsigned_abs()),
);
}
position(origin.column, origin.row.saturating_add(row_offset as u16))
}
fn should_render_static(terminal_size: Size, frame_size: Size) -> bool {
top_two_thirds(terminal_size).size.height < frame_size.height
|| terminal_size.width < frame_size.width
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cycle_duration_stays_in_expected_range() {
let duration = cycle_duration();
assert!(duration >= Duration::from_secs(5));
assert!(duration <= Duration::from_secs(10));
}
#[test]
fn bobbing_frame_starts_at_bottom_extreme() {
assert_eq!(
bobbing_frame(Duration::ZERO),
frame(BobbingPhase::Neutral, BOB_DISTANCE + BASELINE_ROW_OFFSET)
);
}
#[test]
fn bobbing_frame_rises_through_center() {
assert_eq!(
bobbing_frame(cycle_at(1, 4)),
frame(BobbingPhase::Rising, BASELINE_ROW_OFFSET)
);
}
#[test]
fn bobbing_frame_reaches_top_extreme_halfway_through_cycle() {
assert_eq!(
bobbing_frame(cycle_at(1, 2)),
frame(BobbingPhase::Neutral, -BOB_DISTANCE + BASELINE_ROW_OFFSET)
);
}
#[test]
fn bobbing_frame_falls_through_center() {
assert_eq!(
bobbing_frame(cycle_at(3, 4)),
frame(BobbingPhase::Falling, BASELINE_ROW_OFFSET)
);
}
#[test]
fn sinusoidal_motion_changes_by_at_most_one_row_per_tick() {
let mut elapsed = DEFAULT_TICK_RATE;
let mut previous = bobbing_frame(Duration::ZERO).row_offset;
while elapsed < cycle_duration() {
let current = bobbing_frame(elapsed).row_offset;
assert!((current - previous).abs() <= 1);
previous = current;
elapsed += DEFAULT_TICK_RATE;
}
}
#[test]
fn bobbing_frame_wraps_cycle() {
assert_eq!(
bobbing_frame(cycle_duration()),
frame(BobbingPhase::Neutral, BOB_DISTANCE + BASELINE_ROW_OFFSET)
);
}
#[test]
fn small_terminal_renders_static_once() {
let mut buffer = Vec::new();
let frames = MascotFrames::blizz();
let first =
render_cycle_frame(&mut buffer, frames, size(10, 10), Duration::ZERO, false).unwrap();
let after_first = buffer.len();
let second =
render_cycle_frame(&mut buffer, frames, size(10, 10), Duration::ZERO, true).unwrap();
assert!(first);
assert!(second);
assert_eq!(buffer.len(), after_first);
}
#[test]
fn large_terminal_renders_bobbing_frame() {
let mut buffer = Vec::new();
let frames = MascotFrames::blizz();
let rendered_static =
render_cycle_frame(&mut buffer, frames, size(100, 60), Duration::ZERO, false).unwrap();
assert!(!rendered_static);
assert!(!buffer.is_empty());
}
#[test]
fn static_fallback_detects_small_width_or_height() {
let frame_size = size(38, 30);
assert!(should_render_static(size(37, 60), frame_size));
assert!(should_render_static(size(100, 44), frame_size));
assert!(!should_render_static(size(100, 60), frame_size));
}
#[test]
fn row_offsets_saturate_at_screen_top() {
assert_eq!(offset_row(position(5, 0), -2), position(5, 0));
assert_eq!(offset_row(position(5, 5), 2), position(5, 7));
}
#[test]
fn bobbing_origin_applies_row_offset() {
let region = crate::layout::rect(position(0, 0), size(100, 40));
let frame_sz = size(38, 30);
let origin_zero = bobbing_origin(region, frame_sz, 0);
let origin_pos = bobbing_origin(region, frame_sz, 2);
let origin_neg = bobbing_origin(region, frame_sz, -2);
assert_eq!(origin_pos.row, origin_zero.row + 2);
assert!(origin_neg.row <= origin_zero.row);
}
#[test]
fn animation_options_default_has_tick_rate() {
let opts = AnimationOptions::default();
assert_eq!(opts.tick_rate, DEFAULT_TICK_RATE);
}
fn cycle_at(numerator: u32, denominator: u32) -> Duration {
Duration::from_secs_f64(
cycle_duration().as_secs_f64() * f64::from(numerator) / f64::from(denominator),
)
}
}