blizz-ui 3.0.0-dev.11

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

use rand::Rng;

use crate::decode;
use crate::input_buffer::{self, InputBuffer};
use crate::layout as layout_mod;
use crate::prompt::{
  TextEntry, TextEntryLayout, queue_clear_prompt, queue_text_entry, queue_text_entry_with_cursor,
  text_entry, text_entry_layout,
};

#[derive(Debug, Clone)]
pub struct TextEntryComponent {
  pub label: String,
  pub content: String,
  pub open: f64,
  pub label_reveal: f64,
  pub content_reveal: f64,
  pub visible: bool,
}

impl TextEntryComponent {
  /// Text entry chrome for an env-var style prompt (`label` = variable name, `content` = default).
  pub fn prompt(label: impl Into<String>, default_value: impl Into<String>) -> Self {
    Self {
      label: label.into(),
      content: default_value.into(),
      open: 0.0,
      label_reveal: 0.0,
      content_reveal: 0.0,
      visible: true,
    }
  }

  /// Invisible shell when the step is dialog-only or selector-only (no editable field).
  pub fn hidden() -> Self {
    Self {
      label: String::new(),
      content: String::new(),
      open: 0.0,
      label_reveal: 0.0,
      content_reveal: 0.0,
      visible: false,
    }
  }

  /// Wizard decode phase: grows the chrome and reveals labels after the question is half-shown.
  pub fn tick_decode(&mut self, question_reveal: f64, box_open_speed: f64, reveal_speed: f64) {
    if !self.visible || question_reveal < 0.5 {
      return;
    }
    self.open = (self.open + box_open_speed).min(1.0);
    if self.open >= 1.0 {
      self.label_reveal = (self.label_reveal + reveal_speed).min(1.0);
      self.content_reveal = (self.content_reveal + reveal_speed).min(1.0);
    }
  }

  /// Transition encode: scrub label/content; optionally shrink the border when leaving prompt steps.
  pub fn tick_transition_encode(
    &mut self,
    next_has_prompt_box: bool,
    encode_speed: f64,
    morph_speed: f64,
  ) {
    if !self.visible {
      return;
    }
    self.label_reveal = (self.label_reveal - encode_speed).max(0.0);
    self.content_reveal = (self.content_reveal - encode_speed).max(0.0);
    let text_hidden = self.label_reveal <= 0.0 && self.content_reveal <= 0.0;
    if !next_has_prompt_box && text_hidden {
      self.open = (self.open - morph_speed).max(0.0);
    }
  }

  /// Transition decode after `swap_to`: open chrome then fade labels/content in.
  pub fn tick_transition_decode(&mut self, encode_speed: f64, morph_speed: f64) {
    if !self.visible {
      return;
    }
    if self.open < 1.0 {
      self.open = (self.open + morph_speed).min(1.0);
    } else {
      self.label_reveal = (self.label_reveal + encode_speed).min(1.0);
      self.content_reveal = (self.content_reveal + encode_speed).min(1.0);
    }
  }

  /// Exit phase: encode away text then close chrome.
  pub fn tick_exit_close(&mut self, encode_speed: f64, box_close_speed: f64) {
    if !self.visible {
      return;
    }
    self.label_reveal = (self.label_reveal - encode_speed).max(0.0);
    self.content_reveal = (self.content_reveal - encode_speed).max(0.0);
    if self.label_reveal <= 0.0 && self.content_reveal <= 0.0 {
      self.open = (self.open - box_close_speed).max(0.0);
    }
  }

  #[cfg(not(tarpaulin_include))]
  pub fn render_with_cursor<W: Write, R: Rng>(
    &self,
    writer: &mut W,
    layout: &TextEntryLayout,
    tw: u16,
    buf: &InputBuffer,
    rng: &mut R,
  ) -> std::io::Result<u16> {
    if self.open < 1.0 {
      self.render_partial_open(writer, layout, tw)?;
      return Ok(0);
    }

    let label_display = if self.label_reveal >= 1.0 {
      self.label.clone()
    } else {
      let revealed = (self.label.chars().count() as f64 * self.label_reveal).round() as usize;
      decode::decode_frame(&self.label, revealed, rng)
    };

    let content_display = if self.content_reveal >= 1.0 {
      self.content.clone()
    } else {
      let revealed = (self.content.chars().count() as f64 * self.content_reveal).round() as usize;
      decode::decode_frame(&self.content, revealed, rng)
    };

    let frame = text_entry(&label_display, &content_display, tw);
    let label_width = label_display.chars().count() as u16;
    let buf_text = input_buffer::text(buf);
    let cursor_pos = input_buffer::cursor(buf);
    let selection = input_buffer::selection_range(buf);
    queue_text_entry_with_cursor(
      writer,
      layout,
      &frame,
      buf_text,
      label_width,
      cursor_pos,
      selection,
    )
  }

  #[cfg(not(tarpaulin_include))]
  pub fn render_partial_open<W: Write>(
    &self,
    writer: &mut W,
    layout: &TextEntryLayout,
    tw: u16,
  ) -> std::io::Result<()> {
    let full_width = layout.inner_width;
    let raw = ((full_width as f64) * self.open).round() as u16;
    let even = (raw / 2) * 2;
    let current_width = if even == 0 && raw > 0 {
      2
    } else {
      even.min(full_width)
    };

    if current_width == 0 {
      return queue_clear_prompt(writer, layout);
    }

    let partial = TextEntry {
      label: String::new(),
      hint: String::new(),
      inner_width: current_width,
    };
    let partial_layout =
      text_entry_layout(&partial, layout_mod::size(tw, layout.question_row + 10));
    let merged = TextEntryLayout {
      question_row: layout.question_row,
      box_top_row: layout.box_top_row,
      content_row: layout.content_row,
      box_bottom_row: layout.box_bottom_row,
      hint_row: layout.hint_row,
      box_column: partial_layout.box_column,
      inner_width: current_width,
    };
    queue_text_entry(writer, &merged, &partial, "", 0)
  }
}