use crate::rgb::{hsv_to_rgb, Rgb};
use rayon::prelude::*;
use std::collections::HashMap;
use std::sync::Arc;
pub type ColorFn = Arc<dyn Fn(usize, usize, u64, char) -> Rgb + Send + Sync>;
#[derive(Clone)]
pub enum PixelAnimation {
Rainbow { speed: f32 },
Wave { base_color: Rgb },
Pulse { base_color: Rgb },
Blink { on_color: Rgb, off_color: Rgb },
Plasma { scale: f32, speed: f32 },
Ripple { base_color: Rgb, speed: f32 },
Gradient {
from: Rgb,
to: Rgb,
horizontal: bool,
},
#[cfg(feature = "color")]
Transformed {
base: Box<PixelAnimation>,
transform: crate::color::ColorTransform,
},
Custom(ColorFn),
}
impl PixelAnimation {
pub fn custom(f: impl Fn(usize, usize, u64, char) -> Rgb + Send + Sync + 'static) -> Self {
Self::Custom(Arc::new(f))
}
}
pub fn animation_color(
animation: &PixelAnimation,
x: usize,
y: usize,
frame: u64,
ch: char,
width: usize,
height: usize,
) -> Rgb {
pixel_color(animation, x, y, frame, ch, width, height)
}
pub(crate) fn pixel_color(
animation: &PixelAnimation,
x: usize,
y: usize,
frame: u64,
ch: char,
width: usize,
height: usize,
) -> Rgb {
match animation {
PixelAnimation::Rainbow { speed } => {
let hue = (frame as f32 * speed + x as f32 * 6.0 + y as f32 * 10.0) % 360.0;
hsv_to_rgb(hue, 1.0, 1.0)
}
PixelAnimation::Wave { base_color } => {
let span = (width as i64 + 8).max(1);
let pos = (frame as i64) % span;
let dist = (x as i64 - pos).unsigned_abs() as f32;
let brightness = (1.0 - (dist / 4.0).min(1.0)).max(0.15);
base_color.scale(brightness)
}
PixelAnimation::Pulse { base_color } => {
let t = frame as f32 * 0.35;
let brightness = (0.5 + 0.5 * t.sin()).clamp(0.15, 1.0);
base_color.scale(brightness)
}
PixelAnimation::Blink {
on_color,
off_color,
} => {
if frame % 2 == 0 {
*on_color
} else {
*off_color
}
}
PixelAnimation::Plasma { scale, speed } => {
let t = frame as f32 * speed;
let fx = x as f32 * scale;
let fy = y as f32 * scale;
let v = (fx + t).sin()
+ (fy + t).sin()
+ (fx + fy + t).sin()
+ ((fx * fx + fy * fy).sqrt() - t).sin();
let hue = (v * 45.0).rem_euclid(360.0);
hsv_to_rgb(hue, 1.0, 1.0)
}
PixelAnimation::Ripple { base_color, speed } => {
let cx = width as f32 / 2.0;
let cy = height as f32 / 2.0;
let dist = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt();
let phase = dist * 0.5 - frame as f32 * speed;
let brightness = (0.5 + 0.5 * phase.sin()).clamp(0.15, 1.0);
base_color.scale(brightness)
}
PixelAnimation::Gradient {
from,
to,
horizontal,
} => {
let t = if *horizontal {
if width <= 1 {
0.0
} else {
x as f32 / (width - 1) as f32
}
} else if height <= 1 {
0.0
} else {
y as f32 / (height - 1) as f32
};
from.mix(*to, t)
}
#[cfg(feature = "color")]
PixelAnimation::Transformed { base, transform } => {
let color = pixel_color(base, x, y, frame, ch, width, height);
transform.apply(color)
}
PixelAnimation::Custom(f) => f(x, y, frame, ch),
}
}
pub fn colorize_lines<S: AsRef<str>>(
lines: &[S],
animation: &PixelAnimation,
frame: u64,
) -> String {
let (width, height) = line_dims(lines);
colorize_lines_with_dims(lines, width, height, animation, frame)
}
fn colorize_lines_with_dims<S: AsRef<str>>(
lines: &[S],
width: usize,
height: usize,
animation: &PixelAnimation,
frame: u64,
) -> String {
let mut out: Vec<u8> = Vec::with_capacity(height * (width + 1) + 64);
let mut colors: HashMap<Rgb, Vec<u8>> = HashMap::new();
let mut run_color: Option<Rgb> = None;
for (y, line) in lines.iter().enumerate() {
if y > 0 {
out.push(b'\n');
}
for (x, ch) in line.as_ref().chars().enumerate() {
if ch == ' ' {
if run_color.take().is_some() {
out.extend_from_slice(b"\x1b[0m");
}
out.push(b' ');
continue;
}
let color = pixel_color(animation, x, y, frame, ch, width, height);
if run_color != Some(color) {
if run_color.is_some() {
out.extend_from_slice(b"\x1b[0m");
}
let escape = colors.entry(color).or_insert_with(|| {
let mut e = Vec::with_capacity(19);
crate::fast_ansi::push_truecolor_escape(&mut e, color.0, color.1, color.2);
e
});
out.extend_from_slice(escape);
run_color = Some(color);
}
crate::fast_ansi::push_char(&mut out, ch);
}
}
if run_color.is_some() {
out.extend_from_slice(b"\x1b[0m");
}
unsafe { String::from_utf8_unchecked(out) }
}
fn line_dims<S: AsRef<str>>(lines: &[S]) -> (usize, usize) {
let width = lines
.iter()
.map(|l| l.as_ref().chars().count())
.max()
.unwrap_or(0);
(width, lines.len())
}
pub fn colorize_frames<S: AsRef<str> + Sync>(
lines: &[S],
animation: &PixelAnimation,
frames: u64,
) -> Vec<String> {
let (width, height) = line_dims(lines);
(0..frames)
.into_par_iter()
.map(|i| colorize_lines_with_dims(lines, width, height, animation, i))
.collect()
}
fn grid_plain_lines(grid: &iascii::grid::Grid) -> Vec<String> {
grid.rows()
.map(|row| {
let mut line = String::new();
for cell in row {
line.push(cell.ch);
}
line
})
.collect()
}
pub fn colorize_grid(grid: &iascii::grid::Grid, animation: &PixelAnimation, frame: u64) -> String {
colorize_lines(&grid_plain_lines(grid), animation, frame)
}
pub fn colorize_grid_frames(
grid: &iascii::grid::Grid,
animation: &PixelAnimation,
frames: u64,
) -> Vec<String> {
colorize_frames(&grid_plain_lines(grid), animation, frames)
}
pub fn colorize_sequence(frames: &[Vec<String>], animation: &PixelAnimation) -> Vec<String> {
frames
.par_iter()
.enumerate()
.map(|(i, lines)| colorize_lines(lines, animation, i as u64))
.collect()
}
pub fn colorize_joined_sequence(frames: &[String], animation: &PixelAnimation) -> Vec<String> {
frames
.par_iter()
.enumerate()
.map(|(i, frame)| {
let lines: Vec<&str> = frame.lines().collect();
colorize_lines(&lines, animation, i as u64)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn block(rows: &[&str]) -> Vec<String> {
rows.iter().map(|r| r.to_string()).collect()
}
#[test]
fn spaces_are_never_wrapped_in_color_escapes() {
let lines = block(&["# #", " # "]);
let out = colorize_lines(&lines, &PixelAnimation::Rainbow { speed: 10.0 }, 0);
assert!(!out.contains("m \x1b[0m"));
}
#[test]
fn rainbow_colors_every_non_space_cell_and_resets_after_each() {
let lines = block(&["##", "##"]);
let out = colorize_lines(&lines, &PixelAnimation::Rainbow { speed: 5.0 }, 2);
assert_eq!(
out.matches("\x1b[38;2;").count(),
4,
"one color per '#' cell"
);
assert_eq!(out.matches("\x1b[0m").count(), 4);
}
#[test]
fn blink_alternates_between_the_two_configured_colors_by_frame() {
let lines = block(&["#"]);
let animation = PixelAnimation::Blink {
on_color: Rgb::new(255, 0, 0),
off_color: Rgb::new(0, 0, 255),
};
assert!(colorize_lines(&lines, &animation, 0).contains("38;2;255;0;0"));
assert!(colorize_lines(&lines, &animation, 1).contains("38;2;0;0;255"));
}
#[test]
fn colorize_frames_generates_the_requested_number_of_frames() {
let lines = block(&["###"]);
let frames = colorize_frames(
&lines,
&PixelAnimation::Plasma {
scale: 0.3,
speed: 0.2,
},
6,
);
assert_eq!(frames.len(), 6);
for frame in &frames {
assert!(frame.contains("\x1b[38;2;"));
}
}
#[test]
fn custom_closure_receives_the_expected_coordinates_and_character() {
let lines = block(&["ab"]);
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let recorder = seen.clone();
let animation = PixelAnimation::custom(move |x, y, frame, ch| {
recorder.lock().unwrap().push((x, y, frame, ch));
Rgb::WHITE
});
colorize_lines(&lines, &animation, 7);
let calls = seen.lock().unwrap();
assert_eq!(&*calls, &[(0, 0, 7, 'a'), (1, 0, 7, 'b')]);
}
#[test]
fn colorize_sequence_uses_each_frames_own_index_as_animation_time() {
let animation = PixelAnimation::Blink {
on_color: Rgb::new(255, 0, 0),
off_color: Rgb::new(0, 0, 255),
};
let frames = vec![block(&["#"]), block(&["#"])];
let out = colorize_sequence(&frames, &animation);
assert!(out[0].contains("38;2;255;0;0"));
assert!(out[1].contains("38;2;0;0;255"));
}
#[test]
fn colorize_joined_sequence_splits_on_newlines_before_recoloring() {
let animation = PixelAnimation::Blink {
on_color: Rgb::new(255, 0, 0),
off_color: Rgb::new(0, 0, 255),
};
let frames = vec!["##\n##".to_string(), "##\n##".to_string()];
let out = colorize_joined_sequence(&frames, &animation);
assert_eq!(out.len(), 2);
assert!(out[0].lines().count() == 2);
assert!(out[0].contains("38;2;255;0;0"));
assert!(out[1].contains("38;2;0;0;255"));
}
#[test]
fn adjacent_and_cross_row_same_color_cells_share_one_escape_pair() {
let lines = block(&["####", "####", "####"]);
let animation = PixelAnimation::custom(|_, _, _, _| Rgb::new(10, 20, 30));
let out = colorize_lines(&lines, &animation, 0);
assert_eq!(out.matches("\x1b[38;2;10;20;30m").count(), 1);
assert_eq!(out.matches("\x1b[0m").count(), 1);
assert_eq!(out.lines().count(), 3, "row newlines are still present");
}
#[test]
fn a_space_breaks_a_run_even_when_the_color_either_side_matches() {
let lines = block(&["# #"]);
let animation = PixelAnimation::custom(|_, _, _, _| Rgb::new(1, 2, 3));
let out = colorize_lines(&lines, &animation, 0);
assert_eq!(out.matches("\x1b[38;2;1;2;3m").count(), 2);
assert_eq!(out.matches("\x1b[0m").count(), 2);
}
#[test]
fn repeated_non_adjacent_colors_still_produce_the_correct_escapes() {
let red = Rgb::new(255, 0, 0);
let blue = Rgb::new(0, 0, 255);
let animation =
PixelAnimation::custom(move |x, _, _, _| if x % 2 == 0 { red } else { blue });
let out = colorize_lines(&block(&["##########"]), &animation, 0);
assert_eq!(out.matches("\x1b[38;2;255;0;0m").count(), 5);
assert_eq!(out.matches("\x1b[38;2;0;0;255m").count(), 5);
}
#[test]
fn colorize_lines_accepts_borrowed_str_slices_without_cloning() {
let borrowed: Vec<&str> = vec!["#", "#"];
let out = colorize_lines(&borrowed, &PixelAnimation::Rainbow { speed: 4.0 }, 0);
assert!(out.contains("\x1b[38;2;"));
}
#[test]
fn ripple_and_wave_do_not_panic_on_a_single_cell_grid() {
let lines = block(&["#"]);
for animation in [
PixelAnimation::Wave {
base_color: Rgb::WHITE,
},
PixelAnimation::Ripple {
base_color: Rgb::WHITE,
speed: 0.3,
},
] {
let out = colorize_lines(&lines, &animation, 3);
assert!(out.contains("\x1b[38;2;"));
}
}
#[test]
fn gradient_horizontal_starts_at_from_and_ends_at_to() {
let animation = PixelAnimation::Gradient {
from: Rgb::RED,
to: Rgb::BLUE,
horizontal: true,
};
let out = pixel_color(&animation, 0, 0, 0, '#', 4, 1);
assert_eq!(out, Rgb::RED);
let out = pixel_color(&animation, 3, 0, 0, '#', 4, 1);
assert_eq!(out, Rgb::BLUE);
}
#[test]
fn gradient_vertical_starts_at_from_and_ends_at_to() {
let animation = PixelAnimation::Gradient {
from: Rgb::GREEN,
to: Rgb::YELLOW,
horizontal: false,
};
let out = pixel_color(&animation, 0, 0, 0, '#', 1, 3);
assert_eq!(out, Rgb::GREEN);
let out = pixel_color(&animation, 0, 2, 0, '#', 1, 3);
assert_eq!(out, Rgb::YELLOW);
}
#[test]
fn gradient_is_time_independent() {
let animation = PixelAnimation::Gradient {
from: Rgb::RED,
to: Rgb::BLUE,
horizontal: true,
};
let at_frame_0 = pixel_color(&animation, 2, 0, 0, '#', 5, 1);
let at_frame_99 = pixel_color(&animation, 2, 0, 99, '#', 5, 1);
assert_eq!(at_frame_0, at_frame_99);
}
#[test]
fn gradient_single_column_does_not_divide_by_zero() {
let animation = PixelAnimation::Gradient {
from: Rgb::RED,
to: Rgb::BLUE,
horizontal: true,
};
let out = pixel_color(&animation, 0, 0, 0, '#', 1, 1);
assert_eq!(out, Rgb::RED);
}
#[cfg(feature = "color")]
#[test]
fn transformed_grayscale_wraps_rainbow_and_desaturates_it() {
let rainbow = PixelAnimation::Rainbow { speed: 5.0 };
let wrapped = PixelAnimation::Transformed {
base: Box::new(rainbow.clone()),
transform: crate::color::ColorTransform::Grayscale,
};
let plain = pixel_color(&rainbow, 3, 0, 7, '#', 10, 10);
let gray = pixel_color(&wrapped, 3, 0, 7, '#', 10, 10);
assert_ne!(plain, gray);
assert_eq!(gray.g(), gray.b());
}
#[cfg(feature = "color")]
#[test]
fn transformed_none_matches_the_base_animation_exactly() {
let base = PixelAnimation::Ripple {
base_color: Rgb::WHITE,
speed: 0.4,
};
let wrapped = PixelAnimation::Transformed {
base: Box::new(base.clone()),
transform: crate::color::ColorTransform::None,
};
for frame in [0u64, 1, 5] {
assert_eq!(
pixel_color(&base, 2, 3, frame, '#', 8, 8),
pixel_color(&wrapped, 2, 3, frame, '#', 8, 8)
);
}
}
#[cfg(feature = "color")]
#[test]
fn transformed_can_wrap_another_transformed() {
let doubly_wrapped = PixelAnimation::Transformed {
base: Box::new(PixelAnimation::Transformed {
base: Box::new(PixelAnimation::Gradient {
from: Rgb::BLACK,
to: Rgb::WHITE,
horizontal: true,
}),
transform: crate::color::ColorTransform::Invert,
}),
transform: crate::color::ColorTransform::Invert,
};
let plain = PixelAnimation::Gradient {
from: Rgb::BLACK,
to: Rgb::WHITE,
horizontal: true,
};
assert_eq!(
pixel_color(&doubly_wrapped, 4, 0, 0, '#', 10, 1),
pixel_color(&plain, 4, 0, 0, '#', 10, 1)
);
}
#[cfg(feature = "color")]
#[test]
fn colorize_lines_renders_gradient_and_transformed_end_to_end() {
let lines = block(&["#####", "#####"]);
for animation in [
PixelAnimation::Gradient {
from: Rgb::RED,
to: Rgb::BLUE,
horizontal: true,
},
PixelAnimation::Transformed {
base: Box::new(PixelAnimation::Plasma {
scale: 0.3,
speed: 0.2,
}),
transform: crate::color::ColorTransform::Sepia,
},
] {
let out = colorize_lines(&lines, &animation, 1);
assert!(out.contains("\x1b[38;2;"));
assert_eq!(out.lines().count(), 2);
}
}
}