blizz-ui 3.0.0-dev.16

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

use crossterm::{
  queue,
  style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor},
};

use crate::decode;
use crate::{Component, Renderer};

/// 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))]
impl Component for TextComponent {
  fn render<W: Write>(&self, renderer: &mut Renderer<W>) -> std::io::Result<u16> {
    if !self.visible || self.reveal <= 0.0 {
      return Ok(0);
    }
    renderer.with_panel(|writer, _panel, rng| {
      let display: Cow<str> = if self.reveal >= 1.0 {
        Cow::Borrowed(&self.text)
      } else {
        let revealed = (self.text.chars().count() as f64 * self.reveal).round() as usize;
        Cow::Owned(decode::decode_frame(&self.text, revealed, rng))
      };
      queue!(
        writer,
        SetForegroundColor(Color::White),
        SetAttribute(Attribute::Bold),
        Print(&*display),
        SetAttribute(Attribute::Reset),
        ResetColor
      )?;
      Ok(1)
    })
  }
}

#[cfg(test)]
mod tests {
  use super::*;
  use crate::LayoutPanel;
  use crate::test_helpers::seeded_test_renderer;

  fn panel(row: u16) -> LayoutPanel {
    LayoutPanel::centered(80, 20, row)
  }

  #[test]
  fn render_skips_when_invisible() {
    let mut t = TextComponent::new("hi");
    t.visible = false;
    let mut renderer = seeded_test_renderer(123);
    renderer.draw(&t, panel(0)).unwrap();
    assert!(renderer.writer.is_empty());
  }

  #[test]
  fn render_skips_when_reveal_zero() {
    let t = TextComponent::new("hi");
    let mut renderer = seeded_test_renderer(123);
    renderer.draw(&t, panel(0)).unwrap();
    assert!(renderer.writer.is_empty());
  }

  #[test]
  fn render_full_reveal_writes_text() {
    let mut t = TextComponent::new("hello");
    t.reveal = 1.0;
    let mut renderer = seeded_test_renderer(123);
    renderer.draw(&t, panel(5)).unwrap();
    let s = String::from_utf8(renderer.writer).unwrap();
    assert!(s.contains("hello"));
  }

  #[test]
  fn render_partial_writes_output() {
    let mut t = TextComponent::new("hello world");
    t.reveal = 0.3;
    let mut renderer = seeded_test_renderer(123);
    renderer.draw(&t, panel(5)).unwrap();
    assert!(
      !renderer.writer.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");
  }
}