blizz-ui 3.0.0-dev.19

Self-rendering terminal UI components for the blizz wizard
Documentation
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),
    )
  }
}