blizz-ui 3.0.0-dev.11

Self-rendering terminal UI components for the blizz wizard
Documentation
use std::{io::Write, sync::OnceLock};

use crossterm::{
  cursor::MoveTo,
  queue,
  style::{Print, ResetColor},
};

use crate::layout::{Position, Size, centered_block_origin, text_block_size, top_two_thirds};

pub const MASCOT: &str = include_str!("mascot.txt");

static MASCOT_FRAME: OnceLock<Vec<&'static str>> = OnceLock::new();

#[derive(Debug, Clone, Copy)]
pub struct MascotFrames {
  frame: &'static [&'static str],
}

impl MascotFrames {
  pub fn new(frame: &'static [&'static str]) -> Self {
    Self { frame }
  }

  pub fn blizz() -> Self {
    Self::new(lines())
  }

  pub fn lines(&self) -> &'static [&'static str] {
    self.frame
  }

  pub fn size(&self) -> Size {
    text_block_size(self.frame)
  }

  pub fn centered_origin(&self, terminal_size: Size) -> Position {
    centered_block_origin(top_two_thirds(terminal_size), self.size())
  }

  pub fn queue_at<W: Write>(&self, writer: &mut W, origin: Position) -> std::io::Result<()> {
    queue_frame(writer, origin, self.frame)
  }

  pub fn queue_centered<W: Write>(
    &self,
    writer: &mut W,
    terminal_size: Size,
  ) -> std::io::Result<()> {
    self.queue_at(writer, self.centered_origin(terminal_size))
  }
}

impl Default for MascotFrames {
  fn default() -> Self {
    Self::blizz()
  }
}

pub fn lines() -> &'static [&'static str] {
  MASCOT_FRAME.get_or_init(|| block_lines(MASCOT)).as_slice()
}

pub fn size() -> Size {
  MascotFrames::blizz().size()
}

pub fn centered_origin(terminal_size: Size) -> Position {
  MascotFrames::blizz().centered_origin(terminal_size)
}

pub fn queue_frame<W: Write>(
  writer: &mut W,
  origin: Position,
  lines: &[&str],
) -> std::io::Result<()> {
  for (offset, line) in lines.iter().enumerate() {
    queue!(
      writer,
      MoveTo(origin.column, origin.row.saturating_add(offset as u16)),
      Print(line)
    )?;
  }

  queue!(writer, ResetColor)?;
  Ok(())
}

pub fn queue_frame_owned<W: Write>(
  writer: &mut W,
  origin: Position,
  lines: &[String],
) -> std::io::Result<()> {
  for (offset, line) in lines.iter().enumerate() {
    queue!(
      writer,
      MoveTo(origin.column, origin.row.saturating_add(offset as u16)),
      Print(line)
    )?;
  }

  queue!(writer, ResetColor)?;
  Ok(())
}

pub fn queue_centered<W: Write>(writer: &mut W, terminal_size: Size) -> std::io::Result<()> {
  MascotFrames::blizz().queue_centered(writer, terminal_size)
}

fn block_lines(block: &'static str) -> Vec<&'static str> {
  block.trim_end_matches('\n').lines().collect()
}

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

  #[test]
  fn mascot_has_static_dimensions() {
    assert_eq!(MascotFrames::blizz().size(), layout::size(38, 30));
    assert_eq!(size(), layout::size(38, 30));
  }

  #[test]
  fn centered_origin_uses_top_two_thirds() {
    assert_eq!(
      MascotFrames::blizz().centered_origin(layout::size(100, 60)),
      layout::position(31, 5)
    );
    assert_eq!(
      centered_origin(layout::size(100, 60)),
      layout::position(31, 5)
    );
  }

  #[test]
  fn default_frames_use_blizz_mascot() {
    assert_eq!(MascotFrames::default().lines(), lines());
  }

  #[test]
  fn queue_centered_writes_mascot() {
    let mut buffer = Vec::new();

    queue_centered(&mut buffer, layout::size(100, 60)).unwrap();
    let output = String::from_utf8(buffer).unwrap();

    assert!(output.contains(""));
  }

  #[test]
  fn queue_frame_writes_all_lines() {
    let mut buffer = Vec::new();

    queue_frame(&mut buffer, layout::position(0, 0), &["a", "b"]).unwrap();
    let output = String::from_utf8(buffer).unwrap();

    assert!(output.contains("a"));
    assert!(output.contains("b"));
  }

  #[test]
  fn queue_frame_owned_writes_owned_strings() {
    let mut buffer = Vec::new();
    let lines = vec!["hello".to_string(), "world".to_string()];

    queue_frame_owned(&mut buffer, layout::position(0, 0), &lines).unwrap();
    let output = String::from_utf8(buffer).unwrap();

    assert!(output.contains("hello"));
    assert!(output.contains("world"));
  }

  #[test]
  fn queue_at_writes_at_position() {
    let mut buffer = Vec::new();
    let frames = MascotFrames::blizz();

    frames
      .queue_at(&mut buffer, layout::position(5, 3))
      .unwrap();

    assert!(!buffer.is_empty());
  }

  #[test]
  fn new_constructs_from_static_slice() {
    static LINES: &[&str] = &["abc", "def"];
    let frames = MascotFrames::new(LINES);

    assert_eq!(frames.lines(), LINES);
    assert_eq!(frames.size(), layout::size(3, 2));
  }

  #[test]
  fn block_lines_splits_on_newlines() {
    let result = block_lines("a\nb\nc\n");
    assert_eq!(result, vec!["a", "b", "c"]);
  }
}