use std::io::{IsTerminal, Write};
use std::time::{Duration, Instant};
use honk_core::{HonkSpec, synth};
use crate::audio::{self, Playback};
const CAR: [&str; 4] = [
r" ______ ",
r" __/ | \___ o<",
r" |_ _ | _ __| ",
r" (_) (_) ",
];
#[derive(Debug)]
struct BrailleCar {
cols: usize,
rows: usize,
horn_row: usize,
art: &'static str,
}
include!("../assets/braille/cars.rs");
const BLAST_COLS: usize = 12;
const SPARE_ROWS: usize = 3;
const BLAST: [&str; 3] = ["", " )", " ) )"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Art {
Small,
Braille(usize),
}
impl Art {
pub fn for_terminal(size: Option<(u16, u16)>) -> Art {
let Some((cols, rows)) = size else {
return Art::Small;
};
let (cols, rows) = (usize::from(cols), usize::from(rows));
BRAILLE_CARS
.iter()
.find(|car| cols >= car.cols + BLAST_COLS && rows >= car.rows + SPARE_ROWS)
.map_or(Art::Small, |car| Art::Braille(car.cols))
}
fn braille(cols: usize) -> &'static BrailleCar {
BRAILLE_CARS
.iter()
.find(|car| car.cols == cols)
.expect("Art::Braille is only built from BRAILLE_CARS")
}
fn rows(self) -> Vec<&'static str> {
match self {
Art::Small => CAR.to_vec(),
Art::Braille(cols) => Art::braille(cols).art.lines().collect(),
}
}
fn horn_row(self) -> usize {
match self {
Art::Small => 1,
Art::Braille(cols) => Art::braille(cols).horn_row,
}
}
}
pub fn frames(honks: u32, art: Art) -> Vec<String> {
let (rows, horn) = (art.rows(), art.horn_row());
let mut out = Vec::new();
for _ in 0..honks.max(1) {
for (i, blast) in BLAST.iter().enumerate() {
let shout = if i == BLAST.len() - 1 {
" HONK!"
} else {
" "
};
let lines: Vec<String> = rows
.iter()
.enumerate()
.map(|(row, line)| {
let width = line.chars().count() + BLAST_COLS;
let text = if row == horn {
format!("{}{blast:<5}{shout}", line.trim_end())
} else {
(*line).to_string()
};
format!("{text:<width$}")
})
.collect();
out.push(lines.join("\n"));
}
}
out
}
fn ansi_ok() -> bool {
#[cfg(windows)]
{
colored::control::set_virtual_terminal(true).is_ok()
}
#[cfg(not(windows))]
{
true
}
}
pub fn play_with_car(samples: Vec<f32>, spec: &HonkSpec) -> Playback {
let mut stderr = std::io::stderr();
if !stderr.is_terminal() || !ansi_ok() {
return audio::play(samples);
}
let size = terminal_size::terminal_size_of(&stderr).map(|(w, h)| (w.0, h.0));
let art = Art::for_terminal(size);
tracing::debug!(?size, ?art, "terminal size (columns, rows) and chosen car");
let frames = frames(spec.times, art);
let total = Duration::from_secs_f32(synth::duration_secs(spec));
let per_frame = total / frames.len() as u32;
let player = std::thread::spawn(move || audio::play(samples));
let height = art.rows().len();
let start = Instant::now();
for (i, frame) in frames.iter().enumerate() {
if player.is_finished() {
break;
}
if i > 0 {
let _ = write!(stderr, "\x1b[{height}A");
}
let _ = writeln!(stderr, "{frame}");
let _ = stderr.flush();
let next = per_frame * (i as u32 + 1);
if let Some(wait) = next.checked_sub(start.elapsed()) {
std::thread::sleep(wait);
}
}
player
.join()
.unwrap_or(Playback::NoDevice("playback thread panicked".into()))
}
#[cfg(test)]
mod tests {
use super::*;
fn widths(frame: &str) -> Vec<usize> {
frame.lines().map(|l| l.chars().count()).collect()
}
#[test]
fn small_frames_are_plain_ascii_of_equal_height() {
let f = frames(2, Art::Small);
assert!(f.len() >= 4);
let h = f[0].lines().count();
for frame in &f {
assert!(frame.is_ascii(), "{frame}");
assert_eq!(frame.lines().count(), h);
}
}
#[test]
fn every_braille_car_matches_its_table_entry() {
for car in BRAILLE_CARS {
let rows: Vec<&str> = car.art.lines().collect();
assert_eq!(rows.len(), car.rows, "{}", car.cols);
assert!(
rows.iter().all(|r| r.chars().count() == car.cols),
"{}",
car.cols
);
assert!(car.horn_row < car.rows, "{}", car.cols);
assert!(!rows[car.horn_row].trim().is_empty(), "{}", car.cols);
assert!(
car.art
.chars()
.all(|c| c == ' ' || c == '\n' || ('\u{2800}'..='\u{28FF}').contains(&c)),
"{}",
car.cols
);
}
}
#[test]
fn braille_frames_redraw_cleanly_and_say_honk() {
for car in BRAILLE_CARS {
let art = Art::Braille(car.cols);
let f = frames(2, art);
assert!(f.iter().any(|fr| fr.contains("HONK!")), "{}", car.cols);
for frame in f {
assert_eq!(frame.lines().count(), car.rows);
let w = widths(&frame);
assert!(w.iter().all(|&x| x == w[0]), "{} {w:?}", car.cols);
assert!(w[0] <= car.cols + BLAST_COLS, "{}", car.cols);
}
}
}
#[test]
fn the_small_car_says_honk() {
assert!(frames(1, Art::Small).iter().any(|f| f.contains("HONK!")));
}
#[test]
fn the_largest_car_that_fits_wins() {
assert_eq!(Art::for_terminal(Some((200, 50))), Art::Braille(96));
assert_eq!(Art::for_terminal(Some((100, 30))), Art::Braille(76));
assert_eq!(Art::for_terminal(Some((80, 24))), Art::Braille(60));
assert_eq!(Art::for_terminal(Some((60, 20))), Art::Braille(48));
assert_eq!(Art::for_terminal(Some((55, 20))), Art::Braille(40));
assert_eq!(Art::for_terminal(Some((200, 22))), Art::Braille(60));
assert_eq!(Art::for_terminal(Some((100, 12))), Art::Small);
assert_eq!(Art::for_terminal(Some((50, 40))), Art::Small);
assert_eq!(Art::for_terminal(None), Art::Small);
}
}