blizz-ui 3.0.0-dev.9

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

use crossterm::{
  cursor::{Hide, Show},
  execute,
  style::ResetColor,
  terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
};
use unicode_width::UnicodeWidthStr;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Size {
  pub width: u16,
  pub height: u16,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Position {
  pub column: u16,
  pub row: u16,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rect {
  pub origin: Position,
  pub size: Size,
}

pub fn size(width: u16, height: u16) -> Size {
  Size { width, height }
}

pub fn position(column: u16, row: u16) -> Position {
  Position { column, row }
}

pub fn rect(origin: Position, size: Size) -> Rect {
  Rect { origin, size }
}

#[cfg(not(tarpaulin_include))]
pub fn terminal_size() -> std::io::Result<Size> {
  let (width, height) = terminal::size()?;
  Ok(size(width, height))
}

pub fn enter_alternate_screen<W: Write>(writer: &mut W) -> std::io::Result<()> {
  execute!(writer, EnterAlternateScreen, Clear(ClearType::All), Hide)
}

pub fn leave_alternate_screen<W: Write>(writer: &mut W) -> std::io::Result<()> {
  execute!(writer, Show, ResetColor, LeaveAlternateScreen)
}

#[cfg(not(tarpaulin_include))]
pub fn enable_raw_mode() -> std::io::Result<()> {
  terminal::enable_raw_mode()
}

#[cfg(not(tarpaulin_include))]
pub fn disable_raw_mode() -> std::io::Result<()> {
  terminal::disable_raw_mode()
}

pub fn top_two_thirds(terminal_size: Size) -> Rect {
  let height = terminal_size.height.saturating_mul(2) / 3;
  rect(position(0, 0), size(terminal_size.width, height.max(1)))
}

pub fn bottom_third(terminal_size: Size) -> Rect {
  let top = top_two_thirds(terminal_size);
  let row = top.size.height.min(terminal_size.height);
  let height = terminal_size.height.saturating_sub(row);
  rect(position(0, row), size(terminal_size.width, height))
}

pub fn centered_block_origin(region: Rect, block_size: Size) -> Position {
  let column = center_axis(region.origin.column, region.size.width, block_size.width);
  let row = center_axis(region.origin.row, region.size.height, block_size.height);
  position(column, row)
}

pub fn text_block_size(lines: &[&str]) -> Size {
  let width = lines
    .iter()
    .map(|line| UnicodeWidthStr::width(*line))
    .max()
    .unwrap_or(0);

  size(width as u16, lines.len() as u16)
}

pub fn centered_column(terminal_width: u16, content_width: u16) -> u16 {
  terminal_width.saturating_sub(content_width) / 2
}

pub fn lerp_usize(from: usize, to: usize, progress: f64) -> usize {
  let from_f = from as f64;
  let to_f = to as f64;
  (from_f + (to_f - from_f) * progress).round() as usize
}

fn center_axis(origin: u16, region: u16, content: u16) -> u16 {
  origin.saturating_add(region.saturating_sub(content) / 2)
}

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

  #[test]
  fn top_two_thirds_uses_upper_region() {
    let region = top_two_thirds(size(120, 30));

    assert_eq!(region, rect(position(0, 0), size(120, 20)));
  }

  #[test]
  fn bottom_third_starts_after_upper_region() {
    let region = bottom_third(size(120, 30));

    assert_eq!(region, rect(position(0, 20), size(120, 10)));
  }

  #[test]
  fn centered_block_origin_centers_within_region() {
    let region = rect(position(0, 0), size(100, 30));

    assert_eq!(
      centered_block_origin(region, size(20, 10)),
      position(40, 10)
    );
  }

  #[test]
  fn centered_block_origin_keeps_large_blocks_at_region_origin() {
    let region = rect(position(5, 7), size(10, 3));

    assert_eq!(centered_block_origin(region, size(20, 10)), position(5, 7));
  }

  #[test]
  fn text_block_size_uses_display_width() {
    let lines = ["hi", "▓▓"];

    assert_eq!(text_block_size(&lines), size(2, 2));
  }

  #[test]
  fn centered_column_centers_content() {
    assert_eq!(centered_column(80, 20), 30);
    assert_eq!(centered_column(80, 80), 0);
    assert_eq!(centered_column(80, 100), 0);
  }

  #[test]
  fn lerp_usize_interpolates_between_values() {
    assert_eq!(lerp_usize(10, 20, 0.0), 10);
    assert_eq!(lerp_usize(10, 20, 0.5), 15);
    assert_eq!(lerp_usize(10, 20, 1.0), 20);
    assert_eq!(lerp_usize(20, 10, 0.5), 15);
  }

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

    enter_alternate_screen(&mut buffer).unwrap();
    leave_alternate_screen(&mut buffer).unwrap();

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

  #[test]
  fn rect_constructor_creates_expected_value() {
    let r = rect(position(3, 7), size(40, 20));

    assert_eq!(r.origin.column, 3);
    assert_eq!(r.origin.row, 7);
    assert_eq!(r.size.width, 40);
    assert_eq!(r.size.height, 20);
  }

  #[test]
  fn size_and_position_constructors() {
    let s = size(80, 24);
    assert_eq!(s.width, 80);
    assert_eq!(s.height, 24);

    let p = position(10, 5);
    assert_eq!(p.column, 10);
    assert_eq!(p.row, 5);
  }

  #[test]
  fn bottom_third_handles_small_terminal() {
    let region = bottom_third(size(80, 3));
    assert!(region.size.height <= 3);
  }

  #[test]
  fn top_two_thirds_min_height_is_one() {
    let region = top_two_thirds(size(80, 1));
    assert_eq!(region.size.height, 1);
  }
}