blizz-ui 3.0.0-dev.7

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

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

use crate::layout::centered_column;
use crate::prompt::text_entry;

/// Single-column heavy horizontal segment (often called "heavy minus").
const BAR_CHAR: char = '\u{2501}';

/// Countdown strip aligned with prompt text-entry width (`text_entry(..).inner_width`).
#[derive(Debug, Clone)]
pub struct TimerBarComponent {
  pub progress: f64,
  pub visible: bool,
}

impl TimerBarComponent {
  pub fn new() -> Self {
    Self {
      progress: 1.0,
      visible: false,
    }
  }

  pub fn hidden() -> Self {
    Self::new()
  }

  /// Visible segment count when `progress` is in \[0, 1\] and terminal width `tw`.
  pub fn segment_count(progress: f64, tw: u16) -> usize {
    let inner = text_entry("", "", tw).inner_width;
    visible_segments(inner, progress)
  }

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

    let seg = Self::segment_count(self.progress, tw);
    if seg == 0 {
      return Ok(());
    }

    let col = centered_column(tw, seg as u16);
    let body: String = BAR_CHAR.to_string().repeat(seg);
    queue!(
      writer,
      MoveTo(col, row),
      SetForegroundColor(Color::DarkGrey),
      Print(body),
      ResetColor
    )
  }
}

fn visible_segments(inner: u16, progress: f64) -> usize {
  let p = progress.clamp(0.0, 1.0);
  let n = ((inner as f64) * p).floor() as usize;
  n.min(inner as usize)
}

impl Default for TimerBarComponent {
  fn default() -> Self {
    Self::new()
  }
}

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

  #[test]
  fn new_is_hidden_with_full_progress() {
    let t = TimerBarComponent::new();
    assert!(!t.visible);
    assert_eq!(t.progress, 1.0);
  }

  #[test]
  fn segment_count_scales_with_progress() {
    let tw = 80_u16;
    let inner = text_entry("", "", tw).inner_width as usize;
    assert_eq!(TimerBarComponent::segment_count(0.0, tw), 0);
    assert_eq!(TimerBarComponent::segment_count(1.0, tw), inner);
    let half = TimerBarComponent::segment_count(0.5, tw);
    assert!(half > 0 && half < inner);
  }

  #[test]
  fn segment_count_clamps_progress() {
    let tw = 100_u16;
    let inner = text_entry("", "", tw).inner_width as usize;
    assert_eq!(TimerBarComponent::segment_count(-1.0, tw), 0);
    assert_eq!(TimerBarComponent::segment_count(2.0, tw), inner);
  }

  #[test]
  fn render_skips_when_not_visible() {
    let t = TimerBarComponent {
      progress: 1.0,
      visible: false,
    };
    let mut buf = Vec::new();
    t.render(&mut buf, 80, 5).unwrap();
    assert!(buf.is_empty());
  }

  #[test]
  fn render_skips_when_zero_segments() {
    let t = TimerBarComponent {
      progress: 0.0,
      visible: true,
    };
    let mut buf = Vec::new();
    t.render(&mut buf, 80, 5).unwrap();
    assert!(buf.is_empty());
  }

  #[test]
  fn render_writes_bar_when_visible() {
    let n = TimerBarComponent::segment_count(1.0, 80);
    let t = TimerBarComponent {
      progress: 1.0,
      visible: true,
    };
    let mut buf = Vec::new();
    t.render(&mut buf, 80, 3).unwrap();
    let s = String::from_utf8(buf).unwrap();
    assert_eq!(s.matches(BAR_CHAR).count(), n);
  }
}