blizz-ui 3.0.0-dev.17

Self-rendering terminal UI components for the blizz wizard
Documentation
use std::io::Write;

use rand::Rng;

use super::bobbing;
use super::frames::{MascotFrames, queue_frame, queue_frame_owned};
use super::fx;
use crate::layout::{centered_block_origin, top_two_thirds};
use crate::{Component, Renderer};

/// Wizard mascot with entrance/dissolve progress, floating bobbing, and cached distance map.
///
/// Owns all per-frame effect state (tick counters and dissolve tile grid) so
/// the wizard loop doesn't have to manage them externally.
#[derive(Clone, Debug)]
pub struct MascotComponent {
  pub frames: MascotFrames,
  pub entrance: f64,
  pub dissolve: f64,
  pub floating: bool,
  distance_map: fx::DistanceMap,
  entrance_tick: usize,
  dissolve_tick: usize,
  dissolve_state: Option<fx::DissolveState>,
}

impl MascotComponent {
  pub fn blizz() -> Self {
    let frames = MascotFrames::blizz();
    let distance_map = fx::build_distance_map(frames.lines());
    Self {
      frames,
      entrance: 0.0,
      dissolve: 0.0,
      floating: false,
      distance_map,
      entrance_tick: 0,
      dissolve_tick: 0,
      dissolve_state: None,
    }
  }

  /// Advance entrance and dissolve tick counters for the current frame.
  pub fn advance_effect_ticks(&mut self) {
    if self.entrance < 1.0 && self.dissolve <= 0.0 {
      self.entrance_tick += 1;
    }
    if self.dissolve > 0.0 && self.dissolve < 1.0 {
      self.dissolve_tick += 1;
    }
  }

  /// Build and store the dissolve tile grid. Returns `total_ticks` for
  /// the caller to track progress against.
  pub fn init_dissolve(&mut self, rng: &mut Box<dyn Rng>) -> usize {
    let state = fx::build_dissolve_state(self.frames.lines(), rng);
    let total = state.total_ticks;
    self.dissolve_state = Some(state);
    total
  }
}

#[cfg(not(tarpaulin_include))]
impl MascotComponent {
  fn compute_origin(
    &self,
    terminal_size: crate::layout::Size,
    elapsed: std::time::Duration,
  ) -> crate::layout::Position {
    let region = top_two_thirds(terminal_size);
    if self.floating {
      let bob = bobbing::bobbing_frame(elapsed);
      bobbing::bobbing_origin(region, self.frames.size(), bob.row_offset)
    } else {
      centered_block_origin(region, self.frames.size())
    }
  }

  fn render_frame<W: Write, R: Rng>(
    &self,
    writer: &mut W,
    origin: crate::layout::Position,
    rng: &mut R,
  ) -> std::io::Result<()> {
    let lines = self.frames.lines();

    if self.dissolve > 0.0
      && let Some(state) = &self.dissolve_state
    {
      let frame = fx::dissolve_frame(lines, state, self.dissolve_tick, rng);
      return queue_frame_owned(writer, origin, &frame);
    }

    if self.entrance < 1.0 {
      let frame = fx::entrance_frame(lines, &self.distance_map, self.entrance_tick, rng);
      return queue_frame_owned(writer, origin, &frame);
    }

    queue_frame(writer, origin, lines)
  }
}

#[cfg(not(tarpaulin_include))]
impl Component for MascotComponent {
  fn render<W: Write>(&self, renderer: &mut Renderer<W>) -> std::io::Result<u16> {
    if (self.entrance <= 0.0 && self.dissolve <= 0.0) || self.dissolve >= 1.0 {
      return Ok(0);
    }
    let terminal_size = renderer.ctx().terminal_size;
    let elapsed = renderer.ctx().elapsed;
    renderer.with_panel(|writer, _panel, rng| {
      let origin = self.compute_origin(terminal_size, elapsed);
      self.render_frame(writer, origin, rng)?;
      Ok(0)
    })
  }
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn blizz_starts_with_zero_progress() {
    let m = MascotComponent::blizz();
    assert_eq!(m.entrance, 0.0);
    assert_eq!(m.dissolve, 0.0);
    assert!(!m.floating);
    assert!(m.dissolve_state.is_none());
  }

  #[test]
  fn advance_effect_ticks_increments_entrance_when_entering() {
    let mut m = MascotComponent::blizz();
    m.entrance = 0.5;
    m.advance_effect_ticks();
    assert_eq!(m.entrance_tick, 1);
    assert_eq!(m.dissolve_tick, 0);
  }

  #[test]
  fn advance_effect_ticks_increments_dissolve_when_dissolving() {
    let mut m = MascotComponent::blizz();
    m.entrance = 1.0;
    m.dissolve = 0.5;
    m.advance_effect_ticks();
    assert_eq!(
      m.entrance_tick, 0,
      "entrance tick should not advance once entrance is done"
    );
    assert_eq!(m.dissolve_tick, 1);
  }

  #[test]
  fn advance_effect_ticks_noop_when_idle() {
    let mut m = MascotComponent::blizz();
    m.entrance = 1.0;
    m.dissolve = 0.0;
    m.advance_effect_ticks();
    assert_eq!(m.entrance_tick, 0);
    assert_eq!(m.dissolve_tick, 0);
  }

  #[test]
  fn init_dissolve_stores_state_and_returns_total_ticks() {
    let mut m = MascotComponent::blizz();
    let mut rng: Box<dyn rand::Rng> = Box::new(rand::rng());
    let total = m.init_dissolve(&mut rng);
    assert!(total > 0);
    assert!(m.dissolve_state.is_some());
  }
}