blizz-ui 3.0.0-dev.10

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

use crossterm::{
  cursor::MoveTo,
  queue,
  style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor},
};
use rand::Rng;

use crate::decode;
use crate::layout::centered_column;

/// Centered question / message text with progressive decode reveal.
pub struct TextComponent {
  pub text: String,
  pub reveal: f64,
  pub visible: bool,
}

impl TextComponent {
  pub fn new(text: impl Into<String>) -> Self {
    Self {
      text: text.into(),
      reveal: 0.0,
      visible: true,
    }
  }

  #[cfg(not(tarpaulin_include))]
  pub fn render<W: Write, R: Rng>(
    &self,
    writer: &mut W,
    tw: u16,
    row: u16,
    rng: &mut R,
  ) -> std::io::Result<()> {
    if !self.visible || self.reveal <= 0.0 {
      return Ok(());
    }

    let text = &self.text;
    let display = if self.reveal >= 1.0 {
      text.clone()
    } else {
      let revealed = (text.chars().count() as f64 * self.reveal).round() as usize;
      decode::decode_frame(text, revealed, rng)
    };

    let col = centered_column(tw, text.chars().count() as u16);
    queue!(
      writer,
      MoveTo(col, row),
      SetForegroundColor(Color::White),
      SetAttribute(Attribute::Bold),
      Print(&display),
      SetAttribute(Attribute::Reset),
      ResetColor
    )
  }
}

#[cfg(test)]
mod tests {
  use super::*;
  use rand::SeedableRng;
  use rand::rngs::SmallRng;

  fn seeded_rng() -> SmallRng {
    SmallRng::seed_from_u64(123)
  }

  #[test]
  fn render_skips_when_invisible() {
    let mut t = TextComponent::new("hi");
    t.visible = false;
    let mut buf = Vec::new();
    t.render(&mut buf, 80, 0, &mut seeded_rng()).unwrap();
    assert!(buf.is_empty());
  }

  #[test]
  fn render_skips_when_reveal_zero() {
    let t = TextComponent::new("hi");
    let mut buf = Vec::new();
    t.render(&mut buf, 80, 0, &mut seeded_rng()).unwrap();
    assert!(buf.is_empty());
  }

  #[test]
  fn render_full_reveal_writes_text() {
    let mut t = TextComponent::new("hello");
    t.reveal = 1.0;
    let mut buf = Vec::new();
    t.render(&mut buf, 80, 5, &mut seeded_rng()).unwrap();
    let s = String::from_utf8(buf).unwrap();
    assert!(s.contains("hello"));
  }

  #[test]
  fn render_partial_writes_output() {
    let mut t = TextComponent::new("hello world");
    t.reveal = 0.3;
    let mut buf = Vec::new();
    t.render(&mut buf, 80, 5, &mut seeded_rng()).unwrap();
    assert!(
      !buf.is_empty(),
      "partial reveal should emit terminal sequences"
    );
  }

  #[test]
  fn new_sets_visible_defaults() {
    let t = TextComponent::new("q");
    assert!(t.visible);
    assert_eq!(t.reveal, 0.0);
    assert_eq!(t.text, "q");
  }
}