use crate::error::Result;
use crate::text::{hsv_to_rgb, Rgb, TextAnimOptions, TextAnimation};
const GLYPH_HEIGHT: usize = 7;
const GLYPH_WIDTH: usize = 5;
const GLYPH_SPACING: usize = 1;
fn glyph(c: char) -> [&'static str; GLYPH_HEIGHT] {
match c.to_ascii_uppercase() {
'A' => [
".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#",
],
'B' => [
"####.", "#...#", "#...#", "####.", "#...#", "#...#", "####.",
],
'C' => [
".####", "#....", "#....", "#....", "#....", "#....", ".####",
],
'D' => [
"####.", "#...#", "#...#", "#...#", "#...#", "#...#", "####.",
],
'E' => [
"#####", "#....", "#....", "####.", "#....", "#....", "#####",
],
'F' => [
"#####", "#....", "#....", "####.", "#....", "#....", "#....",
],
'G' => [
".####", "#....", "#....", "#.###", "#...#", "#...#", ".####",
],
'H' => [
"#...#", "#...#", "#...#", "#####", "#...#", "#...#", "#...#",
],
'I' => [
"#####", "..#..", "..#..", "..#..", "..#..", "..#..", "#####",
],
'J' => [
"..###", "...#.", "...#.", "...#.", "...#.", "#..#.", ".##..",
],
'K' => [
"#...#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...#",
],
'L' => [
"#....", "#....", "#....", "#....", "#....", "#....", "#####",
],
'M' => [
"#...#", "##.##", "#.#.#", "#...#", "#...#", "#...#", "#...#",
],
'N' => [
"#...#", "##..#", "#.#.#", "#..##", "#...#", "#...#", "#...#",
],
'O' => [
".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###.",
],
'P' => [
"####.", "#...#", "#...#", "####.", "#....", "#....", "#....",
],
'Q' => [
".###.", "#...#", "#...#", "#...#", "#.#.#", "#..#.", ".##.#",
],
'R' => [
"####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#",
],
'S' => [
".####", "#....", "#....", ".###.", "....#", "....#", "####.",
],
'T' => [
"#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#..",
],
'U' => [
"#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###.",
],
'V' => [
"#...#", "#...#", "#...#", "#...#", "#...#", ".#.#.", "..#..",
],
'W' => [
"#...#", "#...#", "#...#", "#.#.#", "#.#.#", "##.##", "#...#",
],
'X' => [
"#...#", ".#.#.", "..#..", "..#..", "..#..", ".#.#.", "#...#",
],
'Y' => [
"#...#", ".#.#.", "..#..", "..#..", "..#..", "..#..", "..#..",
],
'Z' => [
"#####", "....#", "...#.", "..#..", ".#...", "#....", "#####",
],
'0' => [
".###.", "#...#", "#..##", "#.#.#", "##..#", "#...#", ".###.",
],
'1' => [
"..#..", ".##..", "..#..", "..#..", "..#..", "..#..", "#####",
],
'2' => [
".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####",
],
'3' => [
"####.", "....#", "....#", ".###.", "....#", "....#", "####.",
],
'4' => [
"...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#.",
],
'5' => [
"#####", "#....", "#....", "####.", "....#", "....#", "####.",
],
'6' => [
".###.", "#....", "#....", "####.", "#...#", "#...#", ".###.",
],
'7' => [
"#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#...",
],
'8' => [
".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###.",
],
'9' => [
".###.", "#...#", "#...#", ".####", "....#", "....#", ".###.",
],
'!' => [
"..#..", "..#..", "..#..", "..#..", "..#..", ".....", "..#..",
],
'?' => [
".###.", "#...#", "....#", "...#.", "..#..", ".....", "..#..",
],
'.' => [
".....", ".....", ".....", ".....", ".....", ".....", "..#..",
],
',' => [
".....", ".....", ".....", ".....", ".....", "..#..", ".#...",
],
'-' => [
".....", ".....", ".....", "#####", ".....", ".....", ".....",
],
':' => [
".....", "..#..", ".....", ".....", "..#..", ".....", ".....",
],
'\'' => [
"..#..", "..#..", ".....", ".....", ".....", ".....", ".....",
],
_ => [
".....", ".....", ".....", ".....", ".....", ".....", ".....",
],
}
}
pub fn banner_lines(text: &str) -> Vec<String> {
let glyphs: Vec<[&'static str; GLYPH_HEIGHT]> = text.chars().map(glyph).collect();
let row_capacity = glyphs.len() * (GLYPH_WIDTH + GLYPH_SPACING);
(0..GLYPH_HEIGHT)
.map(|row| {
let mut line = String::with_capacity(row_capacity);
for (i, g) in glyphs.iter().enumerate() {
if i > 0 {
line.extend(std::iter::repeat(' ').take(GLYPH_SPACING));
}
line.extend(g[row].chars().map(|c| if c == '.' { ' ' } else { c }));
}
line
})
.collect()
}
fn banner_char_color(
animation: &TextAnimation,
char_index: usize,
total_chars: usize,
frame_index: u64,
opts: &TextAnimOptions,
) -> Option<Rgb> {
match animation {
TextAnimation::Typewriter => {
if char_index <= frame_index as usize {
Some(opts.base_color)
} else {
None
}
}
TextAnimation::Rainbow => {
let hue = ((frame_index as f32) * 12.0 + (char_index as f32) * 18.0) % 360.0;
Some(hsv_to_rgb(hue, 1.0, 1.0))
}
TextAnimation::Wave => {
let span = (total_chars as i64 + 8).max(1);
let pos = (frame_index as i64) % span;
let dist = (char_index as i64 - pos).unsigned_abs() as f32;
let brightness = (1.0 - (dist / 4.0).min(1.0)).max(0.15);
Some(opts.base_color.scale(brightness))
}
TextAnimation::Blink {
on_color,
off_color,
} => Some(if frame_index % 2 == 0 {
*on_color
} else {
*off_color
}),
TextAnimation::Pulse => {
let t = frame_index as f32 * 0.35;
let brightness = (0.5 + 0.5 * t.sin()).clamp(0.15, 1.0);
Some(opts.base_color.scale(brightness))
}
TextAnimation::Marquee { .. } | TextAnimation::Custom(_) => Some(opts.base_color),
}
}
pub fn banner_frames(
text: &str,
animation: &TextAnimation,
opts: &TextAnimOptions,
) -> Result<Vec<Vec<String>>> {
let chars: Vec<char> = text.chars().collect();
let glyphs: Vec<[&'static str; GLYPH_HEIGHT]> = chars.iter().map(|&c| glyph(c)).collect();
let total_chars = chars.len();
let mut frames = Vec::with_capacity(opts.frames as usize);
for frame_index in 0..opts.frames {
let colors: Vec<Option<Rgb>> = (0..total_chars)
.map(|i| banner_char_color(animation, i, total_chars, frame_index, opts))
.collect();
if matches!(animation, TextAnimation::Typewriter) && frame_index as usize >= total_chars {
break;
}
let row_capacity = total_chars * (GLYPH_WIDTH + GLYPH_SPACING + 12);
let rows: Vec<String> = (0..GLYPH_HEIGHT)
.map(|row| {
let mut line = String::with_capacity(row_capacity);
for (i, g) in glyphs.iter().enumerate() {
if i > 0 {
line.extend(std::iter::repeat(' ').take(GLYPH_SPACING));
}
match colors[i] {
Some(color) => {
line.push_str(&color.ansi_fg());
line.extend(g[row].chars().map(|c| if c == '.' { ' ' } else { c }));
line.push_str("\x1b[0m");
}
None => line.extend(std::iter::repeat(' ').take(GLYPH_WIDTH)),
}
}
line
})
.collect();
frames.push(rows);
}
Ok(frames)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::text::TextAnimOptions;
#[test]
fn banner_lines_produces_seven_rows_and_widens_with_more_characters() {
let one = banner_lines("A");
let two = banner_lines("AB");
assert_eq!(one.len(), GLYPH_HEIGHT);
assert_eq!(two.len(), GLYPH_HEIGHT);
for (l1, l2) in one.iter().zip(two.iter()) {
assert!(l2.len() > l1.len());
}
}
#[test]
fn banner_lines_has_no_escape_codes() {
let lines = banner_lines("HI");
for line in &lines {
assert!(!line.contains('\x1b'));
}
}
#[test]
fn unsupported_characters_render_as_a_blank_glyph_without_panicking() {
let lines = banner_lines("A~B");
assert_eq!(lines.len(), GLYPH_HEIGHT);
assert!(lines.iter().any(|l| l.contains('#')));
}
#[test]
fn typewriter_banner_stops_once_every_character_is_revealed() {
let opts = TextAnimOptions {
frames: 100,
..Default::default()
};
let frames = banner_frames("HI", &TextAnimation::Typewriter, &opts).unwrap();
assert_eq!(
frames.len(),
2,
"one frame per character revealed, then stop"
);
}
#[test]
fn typewriter_banner_hides_unrevealed_glyphs_as_blank_columns() {
let opts = TextAnimOptions {
frames: 100,
..Default::default()
};
let frames = banner_frames("HI", &TextAnimation::Typewriter, &opts).unwrap();
let first_ink_rows: usize = frames[0].iter().filter(|l| l.contains('#')).count();
let second_ink_rows: usize = frames[1].iter().filter(|l| l.contains('#')).count();
assert!(second_ink_rows >= first_ink_rows);
}
#[test]
fn rainbow_banner_colors_each_glyph_and_resets_per_row() {
let opts = TextAnimOptions {
frames: 3,
..Default::default()
};
let frames = banner_frames("AB", &TextAnimation::Rainbow, &opts).unwrap();
assert_eq!(frames.len(), 3);
for frame in &frames {
for row in frame {
if row.contains('#') {
assert!(row.contains("\x1b[38;2;"));
assert!(row.ends_with("\x1b[0m"));
}
}
}
}
#[test]
fn wave_banner_sweep_eventually_lights_up_every_character_in_a_long_banner() {
let text = "HELLO WORLD ABCDEFGH"; let total_chars = text.chars().count();
let opts = TextAnimOptions {
frames: (total_chars as u64) + 8, ..Default::default()
};
for char_index in 0..total_chars {
let mut max_brightness = 0.0f32;
for frame_index in 0..opts.frames {
let color = banner_char_color(
&TextAnimation::Wave,
char_index,
total_chars,
frame_index,
&opts,
)
.expect("Wave always returns a color");
let brightness = color.0 as f32 / 255.0;
if brightness > max_brightness {
max_brightness = brightness;
}
}
assert!(
max_brightness > 0.99,
"char_index {char_index} in a {total_chars}-char banner never reached full brightness (max was {max_brightness})"
);
}
}
#[test]
fn typewriter_banner_of_empty_text_produces_no_frames() {
let opts = TextAnimOptions {
frames: 40,
..Default::default()
};
let frames = banner_frames("", &TextAnimation::Typewriter, &opts).unwrap();
assert_eq!(frames.len(), 0);
}
#[test]
fn banner_frames_all_rows_have_matching_length_within_a_frame() {
let opts = TextAnimOptions {
frames: 2,
..Default::default()
};
let frames = banner_frames("OK", &TextAnimation::Pulse, &opts).unwrap();
for frame in &frames {
assert_eq!(frame.len(), GLYPH_HEIGHT);
}
}
}