blizz-ui 3.0.0-dev.12

Self-rendering terminal UI components for the blizz wizard
Documentation
use rand::{Rng, RngExt};

const GARBLE_CHARS: &[u8] =
  b"!@#$%^&*()_+-=[]{}|;:,.<>?`~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

pub fn garble_text<R: Rng>(length: usize, rng: &mut R) -> String {
  (0..length)
    .map(|_| GARBLE_CHARS[rng.random_range(0..GARBLE_CHARS.len())] as char)
    .collect()
}

pub fn garble_frame<R: Rng>(from_len: usize, to_len: usize, progress: f64, rng: &mut R) -> String {
  let interpolated = from_len as f64 + (to_len as f64 - from_len as f64) * progress.clamp(0.0, 1.0);
  let length = interpolated.round().max(1.0) as usize;

  garble_text(length, rng)
}

pub fn decode_frame<R: Rng>(target: &str, revealed_count: usize, rng: &mut R) -> String {
  let chars: Vec<char> = target.chars().collect();
  let revealed = revealed_count.min(chars.len());
  let prefix: String = chars[..revealed].iter().collect();
  let remaining = chars.len().saturating_sub(revealed);

  if remaining == 0 {
    return prefix;
  }

  let suffix = garble_text(remaining, rng);
  format!("{prefix}{suffix}")
}

pub fn encode_frame<R: Rng>(target: &str, garbled_count: usize, rng: &mut R) -> String {
  let chars: Vec<char> = target.chars().collect();
  let garbled = garbled_count.min(chars.len());
  let suffix: String = chars[garbled..].iter().collect();

  if garbled == 0 {
    return suffix;
  }

  let prefix = garble_text(garbled, rng);
  format!("{prefix}{suffix}")
}

pub fn is_fully_revealed(target: &str, revealed_count: usize) -> bool {
  revealed_count >= target.chars().count()
}

pub fn is_fully_garbled(target: &str, garbled_count: usize) -> bool {
  garbled_count >= target.chars().count()
}

/// Combines garble-grow and decode-reveal into a single call.
/// For ticks 0..garble_ticks, grows garbled text from 1 to target length.
/// For ticks >= garble_ticks, progressively reveals the target text.
pub fn decode_text<R: Rng>(target: &str, tick: usize, garble_ticks: usize, rng: &mut R) -> String {
  if tick < garble_ticks {
    garble_frame(1, target.len(), tick as f64 / garble_ticks as f64, rng)
  } else {
    decode_frame(target, tick - garble_ticks, rng)
  }
}

#[cfg(test)]
mod tests {
  use super::*;
  use rand::SeedableRng;
  use rand::rngs::SmallRng;

  fn test_rng() -> SmallRng {
    SmallRng::seed_from_u64(42)
  }

  #[test]
  fn garble_text_produces_correct_length() {
    let mut rng = test_rng();

    assert_eq!(garble_text(10, &mut rng).len(), 10);
    assert_eq!(garble_text(0, &mut rng).len(), 0);
    assert_eq!(garble_text(100, &mut rng).len(), 100);
  }

  #[test]
  fn garble_text_uses_only_valid_chars() {
    let mut rng = test_rng();
    let text = garble_text(200, &mut rng);

    for ch in text.chars() {
      assert!(GARBLE_CHARS.contains(&(ch as u8)), "unexpected char: {ch}");
    }
  }

  #[test]
  fn garble_frame_interpolates_length() {
    let mut rng = test_rng();

    let start = garble_frame(5, 20, 0.0, &mut rng);
    let middle = garble_frame(5, 20, 0.5, &mut rng);
    let end = garble_frame(5, 20, 1.0, &mut rng);

    assert_eq!(start.len(), 5);
    assert_eq!(middle.len(), 13); // round(5 + 15*0.5) = 13
    assert_eq!(end.len(), 20);
  }

  #[test]
  fn garble_frame_clamps_progress() {
    let mut rng = test_rng();

    let below = garble_frame(5, 20, -1.0, &mut rng);
    let above = garble_frame(5, 20, 2.0, &mut rng);

    assert_eq!(below.len(), 5);
    assert_eq!(above.len(), 20);
  }

  #[test]
  fn decode_frame_reveals_prefix_progressively() {
    let mut rng = test_rng();
    let target = "hello world";

    let frame0 = decode_frame(target, 0, &mut rng);
    let frame5 = decode_frame(target, 5, &mut rng);
    let frame_full = decode_frame(target, 11, &mut rng);

    assert_eq!(frame0.chars().count(), target.chars().count());
    assert!(!frame0.starts_with('h'));

    assert!(frame5.starts_with("hello"));
    assert_eq!(frame5.chars().count(), target.chars().count());

    assert_eq!(frame_full, "hello world");
  }

  #[test]
  fn decode_frame_handles_beyond_target_length() {
    let mut rng = test_rng();
    let target = "hi";

    let frame = decode_frame(target, 100, &mut rng);

    assert_eq!(frame, "hi");
  }

  #[test]
  fn is_fully_revealed_checks_char_count() {
    assert!(!is_fully_revealed("hello", 4));
    assert!(is_fully_revealed("hello", 5));
    assert!(is_fully_revealed("hello", 6));
  }

  #[test]
  fn encode_frame_garbles_prefix_progressively() {
    let mut rng = test_rng();
    let target = "hello world";

    let frame0 = encode_frame(target, 0, &mut rng);
    let frame5 = encode_frame(target, 5, &mut rng);
    let frame_full = encode_frame(target, 11, &mut rng);

    assert_eq!(frame0, "hello world");

    assert!(frame5.ends_with(" world"));
    assert_eq!(frame5.chars().count(), target.chars().count());

    assert_eq!(frame_full.chars().count(), target.chars().count());
    assert!(!frame_full.contains("hello"));
  }

  #[test]
  fn encode_frame_handles_beyond_target_length() {
    let mut rng = test_rng();
    let target = "hi";

    let frame = encode_frame(target, 100, &mut rng);

    assert_eq!(frame.chars().count(), 2);
  }

  #[test]
  fn is_fully_garbled_checks_char_count() {
    assert!(!is_fully_garbled("hello", 4));
    assert!(is_fully_garbled("hello", 5));
    assert!(is_fully_garbled("hello", 6));
  }

  #[test]
  fn decode_preserves_multibyte_target() {
    let mut rng = test_rng();
    let target = "café";

    let frame = decode_frame(target, 3, &mut rng);

    assert!(frame.starts_with("caf"));
    assert_eq!(frame.chars().count(), 4);
  }

  #[test]
  fn decode_multibyte_char_count_stable_but_byte_len_varies() {
    let mut rng = test_rng();
    let target = "café résumé";
    let target_chars = target.chars().count();

    for revealed in 0..=target_chars {
      let frame = decode_frame(target, revealed, &mut rng);
      assert_eq!(
        frame.chars().count(),
        target_chars,
        "char count must equal target at revealed={revealed}"
      );
    }

    let partial = decode_frame(target, 2, &mut test_rng());
    assert_ne!(
      partial.len(),
      target.len(),
      "byte length of partially-decoded multi-byte string should differ from target — \
       centering must use chars().count(), not len()"
    );
  }

  #[test]
  fn decode_text_garbles_then_reveals() {
    let mut rng = test_rng();
    let target = "hello";
    let garble_ticks = 5;

    let early = decode_text(target, 2, garble_ticks, &mut rng);
    assert!(!early.is_empty());
    assert_ne!(early, "hello");

    let revealed = decode_text(target, garble_ticks + 10, garble_ticks, &mut rng);
    assert_eq!(revealed, "hello");
  }
}