blizz-ui 3.0.0-dev.17

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::box_chrome;
use crate::decode;
use crate::layout::centered_column;
use crate::prompt::text_entry;

use super::SelectorComponent;

const PREFIX_ACTIVE: &str = "\u{25b8} ";
const PREFIX_INACTIVE: &str = "  ";
const ARROW_UP: char = '\u{25b2}';
const ARROW_DOWN: char = '\u{25bc}';
const ELLIPSIS: &str = " \u{00b7}\u{00b7}\u{00b7} ";
const BORDER_TOP_OFFSET: u16 = 1;

/// Minimum even width for the partial-open animation snap.
const MIN_PARTIAL_WIDTH: u16 = 2;
/// Char width of PREFIX_ACTIVE ("▸ ").
const PREFIX_WIDTH: usize = 2;
/// Char count of ELLIPSIS (" ··· ").
const ELLIPSIS_LEN: usize = 5;

pub(super) const MAX_VISIBLE_OPTIONS: usize = 8;

pub(super) struct SelectorLayout {
  pub terminal_width: u16,
  pub inner_width: u16,
  pub col: u16,
  pub max_visible: usize,
  pub max_label_width: usize,
}

impl SelectorLayout {
  pub fn compute(sel: &SelectorComponent, terminal_width: u16) -> Self {
    let total = sel.options.len();
    let max_visible = total.min(MAX_VISIBLE_OPTIONS);
    let max_label_width = sel
      .options
      .iter()
      .map(|o| o.label.chars().count())
      .max()
      .unwrap_or(0);
    let inner_width = selector_inner_width(max_label_width, terminal_width);
    let outer_width = inner_width + box_chrome::CHROME_WIDTH;
    let col = centered_column(terminal_width, outer_width);
    Self {
      terminal_width,
      inner_width,
      col,
      max_visible,
      max_label_width,
    }
  }
}

fn selector_inner_width(max_label: usize, terminal_width: u16) -> u16 {
  let content_min = (PREFIX_WIDTH + max_label + box_chrome::INNER_PADDING) as u16;
  let entry_width = text_entry("", "", terminal_width).inner_width;
  content_min.max(entry_width)
}

pub(super) fn selector_border(inner_width: u16, has_overflow: bool, top: bool) -> String {
  if !has_overflow {
    return if top {
      box_chrome::top_border(inner_width)
    } else {
      box_chrome::bottom_border(inner_width)
    };
  }

  let (left_corner, right_corner) = if top {
    (box_chrome::CORNER_TOP_LEFT, box_chrome::CORNER_TOP_RIGHT)
  } else {
    (
      box_chrome::CORNER_BOTTOM_LEFT,
      box_chrome::CORNER_BOTTOM_RIGHT,
    )
  };

  let fill_total = inner_width as usize + box_chrome::INNER_PADDING;
  let text_col = PREFIX_WIDTH + 1;
  let leading_space = ELLIPSIS.chars().take_while(|c| *c == ' ').count();
  let prefix_offset = text_col.saturating_sub(leading_space);
  let right_fill = fill_total.saturating_sub(prefix_offset + ELLIPSIS_LEN);
  format!(
    "{}{}{}{}{}",
    left_corner,
    box_chrome::HORIZONTAL.repeat(prefix_offset),
    ELLIPSIS,
    box_chrome::HORIZONTAL.repeat(right_fill),
    right_corner,
  )
}

#[cfg(not(tarpaulin_include))]
pub(super) fn render_partial_border<W: Write>(
  writer: &mut W,
  open_progress: f64,
  layout: &SelectorLayout,
  start_row: u16,
) -> std::io::Result<usize> {
  let raw = ((layout.inner_width as f64) * open_progress).round() as u16;
  let current_width = box_chrome::snap_even(raw, MIN_PARTIAL_WIDTH).min(layout.inner_width);

  if current_width == 0 {
    return Ok(0);
  }

  let col = centered_column(
    layout.terminal_width,
    current_width + box_chrome::CHROME_WIDTH,
  );

  queue_border_line(writer, col, start_row, current_width, true)?;
  queue_empty_rows(writer, col, start_row, current_width, layout.max_visible)?;
  queue_border_line(
    writer,
    col,
    start_row + BORDER_TOP_OFFSET + layout.max_visible as u16,
    current_width,
    false,
  )?;

  Ok(layout.max_visible + box_chrome::BORDER_ROWS as usize)
}

#[cfg(not(tarpaulin_include))]
fn queue_border_line<W: Write>(
  writer: &mut W,
  col: u16,
  row: u16,
  inner_width: u16,
  top: bool,
) -> std::io::Result<()> {
  let border = selector_border(inner_width, false, top);
  queue!(
    writer,
    MoveTo(col, row),
    SetForegroundColor(Color::DarkGrey),
    Print(&border),
    ResetColor
  )
}

#[cfg(not(tarpaulin_include))]
fn queue_empty_rows<W: Write>(
  writer: &mut W,
  col: u16,
  start_row: u16,
  inner_width: u16,
  count: usize,
) -> std::io::Result<()> {
  let empty_inner: String = " ".repeat(inner_width as usize + box_chrome::INNER_PADDING);
  for i in 0..count {
    let row = start_row + BORDER_TOP_OFFSET + i as u16;
    queue!(
      writer,
      MoveTo(col, row),
      SetForegroundColor(Color::DarkGrey),
      Print(box_chrome::VERTICAL),
      Print(&empty_inner),
      Print(box_chrome::VERTICAL),
      ResetColor
    )?;
  }
  Ok(())
}

#[cfg(not(tarpaulin_include))]
pub(super) fn render_selector_items<W: Write, R: Rng>(
  writer: &mut W,
  sel: &SelectorComponent,
  layout: &SelectorLayout,
  start_row: u16,
  has_scroll_up: bool,
  has_scroll_down: bool,
  rng: &mut R,
) -> std::io::Result<()> {
  let pad_width = (layout.inner_width as usize)
    .saturating_sub(PREFIX_WIDTH + layout.max_label_width + box_chrome::INNER_PADDING);

  for i in 0..layout.max_visible {
    let opt_idx = sel.scroll_offset + i;
    if opt_idx >= sel.options.len() {
      break;
    }
    let arrow = scroll_arrow_for(i, layout.max_visible, has_scroll_up, has_scroll_down);
    render_option_row(
      writer,
      sel,
      OptionRowCtx {
        layout,
        row: start_row + i as u16,
        opt_idx,
        pad_width,
        arrow_char: arrow,
      },
      rng,
    )?;
  }

  Ok(())
}

struct OptionRowCtx<'a> {
  layout: &'a SelectorLayout,
  row: u16,
  opt_idx: usize,
  pad_width: usize,
  arrow_char: char,
}

#[cfg(not(tarpaulin_include))]
fn render_option_row<W: Write, R: Rng>(
  writer: &mut W,
  sel: &SelectorComponent,
  ctx: OptionRowCtx<'_>,
  rng: &mut R,
) -> std::io::Result<()> {
  let opt = &sel.options[ctx.opt_idx];
  let is_selected = ctx.opt_idx == sel.selected;
  let label = decode_label(&opt.label, sel.reveal, rng);

  queue_left_border(writer, ctx.layout.col, ctx.row)?;
  queue_option_label(writer, &label, is_selected, ctx.layout.max_label_width)?;
  queue_right_border(writer, ctx.pad_width, ctx.arrow_char)
}

fn decode_label<R: Rng>(label: &str, reveal: f64, rng: &mut R) -> String {
  if reveal < 1.0 {
    let revealed = (label.chars().count() as f64 * reveal).round() as usize;
    decode::decode_frame(label, revealed, rng)
  } else {
    label.to_string()
  }
}

#[cfg(not(tarpaulin_include))]
fn queue_left_border<W: Write>(writer: &mut W, col: u16, row: u16) -> std::io::Result<()> {
  queue!(
    writer,
    MoveTo(col, row),
    SetForegroundColor(Color::DarkGrey),
    Print(box_chrome::VERTICAL),
    Print(" "),
  )
}

#[cfg(not(tarpaulin_include))]
fn queue_option_label<W: Write>(
  writer: &mut W,
  label: &str,
  is_selected: bool,
  max_label_width: usize,
) -> std::io::Result<()> {
  let prefix = if is_selected {
    PREFIX_ACTIVE
  } else {
    PREFIX_INACTIVE
  };
  let padded_label = format!("{:<width$}", label, width = max_label_width);

  if is_selected {
    queue!(
      writer,
      SetForegroundColor(Color::White),
      SetAttribute(Attribute::Bold),
      Print(prefix),
      Print(&padded_label),
      SetAttribute(Attribute::Reset),
    )
  } else {
    queue!(
      writer,
      SetForegroundColor(Color::DarkGrey),
      Print(prefix),
      Print(&padded_label),
    )
  }
}

#[cfg(not(tarpaulin_include))]
fn queue_right_border<W: Write>(
  writer: &mut W,
  pad_width: usize,
  arrow_char: char,
) -> std::io::Result<()> {
  let gap: String = " ".repeat(pad_width + 1);
  queue!(
    writer,
    SetForegroundColor(Color::DarkGrey),
    Print(format!("{gap}{arrow_char}")),
    Print(format!(" {}", box_chrome::VERTICAL)),
    ResetColor
  )
}

pub(super) fn scroll_arrow_for(
  i: usize,
  max_visible: usize,
  has_scroll_up: bool,
  has_scroll_down: bool,
) -> char {
  if i == 0 && has_scroll_up {
    ARROW_UP
  } else if i == max_visible - 1 && has_scroll_down {
    ARROW_DOWN
  } else {
    ' '
  }
}

#[cfg(test)]
mod tests {
  use super::*;
  use crate::test_helpers::select_option as option;

  #[test]
  fn scroll_arrow_for_branches() {
    assert_eq!(scroll_arrow_for(0, 4, true, false), ARROW_UP);
    assert_eq!(scroll_arrow_for(3, 4, false, true), ARROW_DOWN);
    assert_eq!(scroll_arrow_for(1, 4, true, true), ' ');
    assert_eq!(scroll_arrow_for(3, 4, true, false), ' ');
  }

  #[test]
  fn selector_layout_compute_places_column() {
    let sel = SelectorComponent::from_options(
      vec![option("short", "s"), option("much longer label", "m")],
      0,
    );
    let layout = SelectorLayout::compute(&sel, 120);
    assert_eq!(layout.max_visible, 2);
    assert_eq!(layout.terminal_width, 120);
    assert!(layout.inner_width > 0);
    assert!(layout.col < 120);
    assert_eq!(layout.max_label_width, "much longer label".len());

    let empty = SelectorComponent::hidden();
    let layout_empty = SelectorLayout::compute(&empty, 80);
    assert_eq!(layout_empty.max_visible, 0);
    assert_eq!(layout_empty.max_label_width, 0);
  }

  #[test]
  fn selector_inner_width_tracks_longest_label() {
    let w = selector_inner_width(6, 200);
    assert!(w >= 6);

    let w0 = selector_inner_width(0, 80);
    assert!(w0 > 0);
  }

  #[test]
  fn selector_border_plain_and_overflow() {
    let plain_top = selector_border(10, false, true);
    assert!(plain_top.starts_with(box_chrome::CORNER_TOP_LEFT));
    assert!(plain_top.ends_with(box_chrome::CORNER_TOP_RIGHT));

    let plain_bot = selector_border(10, false, false);
    assert!(plain_bot.starts_with(box_chrome::CORNER_BOTTOM_LEFT));

    let overflow_top = selector_border(20, true, true);
    assert!(overflow_top.contains('\u{00b7}'));

    let overflow_bot = selector_border(20, true, false);
    assert!(overflow_bot.starts_with(box_chrome::CORNER_BOTTOM_LEFT));
    assert!(overflow_bot.ends_with(box_chrome::CORNER_BOTTOM_RIGHT));
    assert!(overflow_bot.contains('\u{00b7}'));
  }

  #[test]
  fn decode_label_fully_revealed() {
    let mut rng = rand::rng();
    let result = decode_label("Hello", 1.0, &mut rng);
    assert_eq!(result, "Hello");
  }

  #[test]
  fn decode_label_partially_revealed() {
    let mut rng = rand::rng();
    let result = decode_label("Hello", 0.5, &mut rng);
    assert_eq!(result.chars().count(), 5);
    assert!(result.starts_with("He") || result.starts_with("Hel"));
  }
}