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" (_) (_) ",
];
const BRAILLE_CAR: &str = include_str!("../assets/car-braille.txt");
const BRAILLE_HORN_ROW: usize = 2;
const BLAST: [&str; 3] = ["", " )", " ) )"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Art {
Small,
Braille,
}
impl Art {
pub fn for_terminal(size: Option<(u16, u16)>) -> Art {
match size {
Some((cols, rows)) if cols >= 80 && rows >= 18 => Art::Braille,
_ => Art::Small,
}
}
fn rows(self) -> Vec<&'static str> {
match self {
Art::Small => CAR.to_vec(),
Art::Braille => BRAILLE_CAR.lines().collect(),
}
}
fn horn_row(self) -> usize {
match self {
Art::Small => 1,
Art::Braille => BRAILLE_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() + 12;
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);
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 braille_frames_are_16_rows_that_redraw_cleanly() {
for frame in frames(2, Art::Braille) {
assert_eq!(frame.lines().count(), 16);
let w = widths(&frame);
assert!(w.iter().all(|&x| x == w[0]), "{w:?}");
assert!(w[0] <= 76, "too wide: {}", w[0]);
}
}
#[test]
fn the_braille_car_is_drawn_in_braille_and_spaces() {
let car: String = frames(1, Art::Braille)[0].lines().collect();
assert!(
car.chars()
.all(|c| c == ' ' || ('\u{2800}'..='\u{28FF}').contains(&c))
);
}
#[test]
fn both_cars_say_honk() {
for art in [Art::Small, Art::Braille] {
assert!(
frames(1, art).iter().any(|f| f.contains("HONK!")),
"{art:?}"
);
}
}
#[test]
fn the_big_car_needs_room() {
assert_eq!(Art::for_terminal(Some((80, 24))), Art::Braille);
assert_eq!(Art::for_terminal(Some((79, 24))), Art::Small);
assert_eq!(Art::for_terminal(Some((120, 17))), Art::Small);
assert_eq!(Art::for_terminal(None), Art::Small);
}
}