use std::io::{self, IsTerminal, Write};
use std::time::{Duration, Instant};
use crossterm::{
cursor::{Hide, MoveTo, Show},
execute, queue,
style::{Print, ResetColor},
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
};
use crate::frame::{self, Paint};
use crate::{art::Art, easing::Easing, guard, rank::RankMap};
const GLOW_LEVELS: u8 = 8;
pub(crate) const SYNC_BEGIN: &str = "\x1b[?2026h";
pub(crate) const SYNC_END: &str = "\x1b[?2026l";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ColorDepth {
Mono,
Ansi16,
#[default]
Ansi256,
TrueColor,
}
impl ColorDepth {
pub fn detect() -> Self {
let var = |k: &str| std::env::var(k).unwrap_or_default().to_ascii_lowercase();
if std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()) {
return ColorDepth::Mono;
}
let term = var("TERM");
if term == "dumb" {
return ColorDepth::Mono;
}
let colorterm = var("COLORTERM");
if colorterm.contains("truecolor") || colorterm.contains("24bit") {
return ColorDepth::TrueColor;
}
if std::env::var_os("WT_SESSION").is_some() || (cfg!(windows) && term.is_empty()) {
return ColorDepth::TrueColor;
}
if term.contains("256color") {
return ColorDepth::Ansi256;
}
if term.contains("16color") || term == "linux" {
return ColorDepth::Ansi16;
}
ColorDepth::Ansi256
}
#[inline]
pub fn is_color(self) -> bool {
self != ColorDepth::Mono
}
pub fn quantize(self, (r, g, b): (u8, u8, u8)) -> Option<Fg> {
match self {
ColorDepth::Mono => None,
ColorDepth::TrueColor => Some(Fg::Rgb(r, g, b)),
ColorDepth::Ansi256 => Some(Fg::Indexed(ansi256(r, g, b))),
ColorDepth::Ansi16 => Some(Fg::Basic(ansi16(r, g, b))),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Fg {
Rgb(u8, u8, u8),
Indexed(u8),
Basic(u8),
}
pub(crate) const FG_RESET: &str = "\x1b[39m";
impl std::fmt::Display for Fg {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
Fg::Rgb(r, g, b) => write!(f, "\x1b[38;2;{r};{g};{b}m"),
Fg::Indexed(i) => write!(f, "\x1b[38;5;{i}m"),
Fg::Basic(i @ 0..=7) => write!(f, "\x1b[{}m", 30 + i as u16),
Fg::Basic(i) => write!(f, "\x1b[{}m", 90 + (i.min(15) - 8) as u16),
}
}
}
fn ansi256(r: u8, g: u8, b: u8) -> u8 {
let (lo, hi) = (r.min(g).min(b) as i32, r.max(g).max(b) as i32);
if hi - lo < 12 {
let level = ((r as i32 + g as i32 + b as i32) / 3 - 8).clamp(0, 238);
return 232 + (level * 23 / 238) as u8;
}
let step = |v: u8| -> u8 {
match v {
0..=47 => 0,
48..=114 => 1,
115..=154 => 2,
155..=194 => 3,
195..=234 => 4,
_ => 5,
}
};
16 + 36 * step(r) + 6 * step(g) + step(b)
}
fn ansi16(r: u8, g: u8, b: u8) -> u8 {
const PALETTE: [(u8, u8, u8); 16] = [
(0, 0, 0),
(128, 0, 0),
(0, 128, 0),
(128, 128, 0),
(0, 0, 128),
(128, 0, 128),
(0, 128, 128),
(192, 192, 192),
(128, 128, 128),
(255, 0, 0),
(0, 255, 0),
(255, 255, 0),
(0, 0, 255),
(255, 0, 255),
(0, 255, 255),
(255, 255, 255),
];
let dist = |&(pr, pg, pb): &(u8, u8, u8)| {
let d = |a: u8, b: u8| (a as i32 - b as i32).pow(2);
d(pr, r) + d(pg, g) + d(pb, b)
};
PALETTE
.iter()
.enumerate()
.min_by_key(|(_, c)| dist(c))
.map(|(i, _)| i as u8)
.unwrap_or(7)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Palette {
#[default]
Glow,
Rainbow,
}
#[derive(Clone, Copy, Debug)]
pub struct Style {
pub feather: f32,
pub body: (u8, u8, u8),
pub head: (u8, u8, u8),
pub depth: ColorDepth,
pub palette: Palette,
pub caption: (u8, u8, u8),
}
impl Default for Style {
fn default() -> Self {
Style {
feather: 0.07,
body: (120, 134, 168),
head: (255, 226, 138),
depth: ColorDepth::detect(),
palette: Palette::Glow,
caption: (120, 134, 168),
}
}
}
impl Style {
pub fn rainbow() -> Self {
Style {
palette: Palette::Rainbow,
..Style::default()
}
}
pub fn light() -> Self {
Style {
body: (72, 84, 112),
head: (176, 106, 12),
caption: (96, 106, 130),
..Style::default()
}
}
pub fn monochrome() -> Self {
Style {
depth: ColorDepth::Mono,
..Style::default()
}
}
#[inline]
pub fn is_color(&self) -> bool {
self.depth.is_color()
}
}
fn blend(a: (u8, u8, u8), b: (u8, u8, u8), s: f32) -> (u8, u8, u8) {
let lerp = |x: u8, y: u8| {
(x as f32 + (y as f32 - x as f32) * s)
.round()
.clamp(0.0, 255.0) as u8
};
(lerp(a.0, b.0), lerp(a.1, b.1), lerp(a.2, b.2))
}
#[inline]
fn settle(style: &Style, progress: f32, rank: f32) -> f32 {
if style.feather <= 0.0 {
1.0
} else {
((progress - rank) / style.feather).clamp(0.0, 1.0)
}
}
pub(crate) fn frontier_rgb(style: &Style, progress: f32, rank: f32) -> (u8, u8, u8) {
blend(style.head, style.body, settle(style, progress, rank))
}
pub(crate) fn cell_rgb(
style: &Style,
progress: f32,
rank: f32,
x: u16,
y: u16,
t: f32,
) -> (u8, u8, u8) {
match style.palette {
Palette::Glow => frontier_rgb(style, progress, rank),
Palette::Rainbow => rainbow_rgb(x, y, t),
}
}
fn rainbow_rgb(x: u16, y: u16, t: f32) -> (u8, u8, u8) {
let hue = (x as f32 * 0.05 + y as f32 * 0.12 + t * 0.4).rem_euclid(1.0);
hsl_to_rgb(hue, 0.95, 0.62)
}
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = h * 6.0;
let x = c * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs());
let (r, g, b) = match hp as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
let to = |v: f32| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
(to(r), to(g), to(b))
}
#[derive(Clone, Copy)]
pub(crate) struct Scene<'a> {
pub art: &'a Art,
pub ranks: &'a RankMap,
pub style: &'a Style,
}
pub(crate) fn queue_row<W: Write>(
out: &mut W,
scene: Scene<'_>,
progress: f32,
t: f32,
y: u16,
budget: u16,
) -> io::Result<()> {
let Scene { art, ranks, style } = scene;
let mut cells: Vec<(u16, Paint)> = Vec::with_capacity(art.width() as usize);
for (x, at, paint) in frame::row(art, ranks, progress, y) {
if at.saturating_add(paint.cols()) > budget {
break;
}
cells.push((x, paint));
}
while matches!(cells.last(), Some((_, Paint::Blank { .. }))) {
cells.pop();
}
let mut current: Option<Fg> = None;
for (x, paint) in cells {
match paint {
Paint::Blank { cols } => {
if current.take().is_some() {
write!(out, "{FG_RESET}")?;
}
for _ in 0..cols {
out.write_all(b" ")?;
}
}
Paint::Ink { glyph, .. } => {
let color = ranks
.rank_at(x, y)
.and_then(|r| style.depth.quantize(cell_rgb(style, progress, r, x, y, t)));
if color != current {
match color {
Some(c) => write!(out, "{c}")?,
None => write!(out, "{FG_RESET}")?,
}
current = color;
}
write!(out, "{glyph}")?;
}
}
}
if current.is_some() {
write!(out, "{FG_RESET}")?;
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Viewport {
pub cols: u16,
pub rows: u16,
}
impl Viewport {
pub fn detect() -> Self {
let (cols, rows) = terminal::size().unwrap_or((80, 24));
Viewport {
cols: cols.max(1),
rows: rows.max(1),
}
}
pub fn fit(&self, art: &Art, reserve_rows: u16) -> Fit {
let art_w = frame::art_cols(art);
let usable_rows = self.rows.saturating_sub(reserve_rows);
Fit {
ox: self.cols.saturating_sub(art_w) / 2,
oy: usable_rows.saturating_sub(art.height()) / 2,
cols: art_w.min(self.cols),
rows: art.height().min(usable_rows),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Fit {
pub ox: u16,
pub oy: u16,
pub cols: u16,
pub rows: u16,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum CellState {
Hidden,
Lit(u8),
}
pub struct Reveal<'a> {
art: &'a Art,
ranks: &'a RankMap,
style: Style,
state: Vec<CellState>,
out: io::Stdout,
viewport: Viewport,
fit: Fit,
active: bool,
}
impl<'a> Reveal<'a> {
pub fn new(art: &'a Art, ranks: &'a RankMap, style: Style) -> io::Result<Self> {
let mut out = io::stdout();
let active = out.is_terminal();
let viewport = if active {
Viewport::detect()
} else {
Viewport { cols: 80, rows: 24 }
};
let fit = viewport.fit(art, 0);
if active {
guard::arm();
execute!(out, EnterAlternateScreen, Hide, Clear(ClearType::All))?;
guard::set_alt_screen(true);
guard::set_cursor_hidden(true);
}
Ok(Reveal {
art,
ranks,
style,
state: vec![CellState::Hidden; art.cell_count()],
out,
viewport,
fit,
active,
})
}
pub fn render(&mut self, progress: f32) -> io::Result<()> {
if !self.active {
return Ok(());
}
let viewport = Viewport::detect();
if viewport != self.viewport {
self.viewport = viewport;
self.fit = viewport.fit(self.art, 0);
self.state.fill(CellState::Hidden);
execute!(self.out, Clear(ClearType::All))?;
}
self.paint(progress)
}
fn paint(&mut self, progress: f32) -> io::Result<()> {
let (art, ranks, style, fit) = (self.art, self.ranks, &self.style, self.fit);
let mut dirty = false;
for y in 0..fit.rows {
for (x, at, cell) in frame::row(art, ranks, progress, y) {
if at.saturating_add(cell.cols()) > fit.cols {
break;
}
let idx = art.index(x, y);
let target = match cell {
Paint::Blank { .. } => CellState::Hidden,
Paint::Ink { .. } => {
let rank = ranks.rank_at(x, y).unwrap_or(0.0);
let level = match style.palette {
Palette::Rainbow => GLOW_LEVELS,
Palette::Glow => {
(settle(style, progress, rank) * GLOW_LEVELS as f32).round() as u8
}
};
CellState::Lit(level)
}
};
if self.state[idx] == target {
continue;
}
if !dirty {
queue!(self.out, Print(SYNC_BEGIN))?;
dirty = true;
}
queue!(self.out, MoveTo(fit.ox + at, fit.oy + y))?;
match (target, cell) {
(CellState::Hidden, _) => {
for _ in 0..cell.cols() {
queue!(self.out, Print(' '))?;
}
}
(CellState::Lit(level), Paint::Ink { glyph, .. }) => {
let rgb = match style.palette {
Palette::Rainbow => rainbow_rgb(x, y, 0.0),
Palette::Glow => {
blend(style.head, style.body, level as f32 / GLOW_LEVELS as f32)
}
};
if let Some(c) = style.depth.quantize(rgb) {
write!(self.out, "{c}")?;
}
write!(self.out, "{glyph}")?;
}
(CellState::Lit(_), Paint::Blank { .. }) => unreachable!(),
}
self.state[idx] = target;
}
}
if dirty {
write!(self.out, "{FG_RESET}")?;
queue!(self.out, Print(SYNC_END))?;
self.out.flush()?;
}
Ok(())
}
pub fn finish(mut self) -> io::Result<()> {
self.restore()?;
write!(self.out, "{}", frame::to_string(self.art, self.ranks, 1.0))?;
self.out.flush()
}
fn restore(&mut self) -> io::Result<()> {
if self.active {
self.active = false;
execute!(self.out, ResetColor, Show, LeaveAlternateScreen)?;
guard::set_alt_screen(false);
guard::set_cursor_hidden(false);
}
Ok(())
}
}
impl Drop for Reveal<'_> {
fn drop(&mut self) {
let _ = self.restore();
}
}
pub fn animate(
art: &Art,
ranks: &RankMap,
style: Style,
duration: Duration,
easing: Easing,
) -> io::Result<()> {
if !io::stdout().is_terminal() {
print!("{}", frame::to_string(art, ranks, 1.0));
return Ok(());
}
let mut reveal = Reveal::new(art, ranks, style)?;
let total = duration.as_secs_f32().max(0.001);
let frame_time = Duration::from_millis(16); let start = Instant::now();
for tick in 1u32.. {
let t = (start.elapsed().as_secs_f32() / total).min(1.0);
reveal.render(easing.apply(t))?;
if t >= 1.0 {
break;
}
if let Some(remaining) = (start + frame_time * tick).checked_duration_since(Instant::now())
{
std::thread::sleep(remaining);
}
}
reveal.finish()
}
pub use crate::frame::art_cols;
pub use crate::width::{glyph_cols as display_cols, truncate_to_cols};
#[cfg(test)]
mod tests {
use super::*;
use crate::ordering::{Geodesic, Ordering};
fn row_bytes(
art: &Art,
ranks: &RankMap,
style: &Style,
progress: f32,
y: u16,
budget: u16,
) -> String {
let mut buf: Vec<u8> = Vec::new();
let scene = Scene { art, ranks, style };
queue_row(&mut buf, scene, progress, 0.0, y, budget).unwrap();
String::from_utf8(buf).unwrap()
}
#[test]
fn monochrome_rows_carry_no_escapes() {
let art = Art::parse("####");
let ranks = Geodesic::default().rank(&art);
let out = row_bytes(&art, &ranks, &Style::monochrome(), 1.0, 0, 80);
assert_eq!(out, "####");
}
#[test]
fn rows_are_clipped_to_the_budget() {
let art = Art::parse("##########");
let ranks = Geodesic::default().rank(&art);
let out = row_bytes(&art, &ranks, &Style::monochrome(), 1.0, 0, 4);
assert_eq!(out, "####");
}
#[cfg(feature = "unicode")]
#[test]
fn clipping_never_splits_a_wide_glyph() {
let art = Art::parse("世界");
let ranks = Geodesic::default().rank(&art);
assert_eq!(
row_bytes(&art, &ranks, &Style::monochrome(), 1.0, 0, 3),
"世"
);
assert_eq!(
row_bytes(&art, &ranks, &Style::monochrome(), 1.0, 0, 4),
"世界"
);
}
#[cfg(feature = "unicode")]
#[test]
fn hidden_wide_glyphs_hold_their_columns() {
use crate::width::str_cols;
let art = Art::parse("世a");
let mut ranks = RankMap::new(art.width(), art.height());
ranks.set(0, 0, 1.0); ranks.set(1, 0, 0.0);
let early = row_bytes(&art, &ranks, &Style::monochrome(), 0.5, 0, 80);
assert_eq!(early, " a", "hidden wide glyph must reserve two columns");
assert_eq!(str_cols(&early), 3);
assert_eq!(
str_cols(&row_bytes(&art, &ranks, &Style::monochrome(), 1.0, 0, 80)),
3
);
}
#[test]
fn trailing_blanks_are_trimmed() {
let art = Art::parse("# #");
let mut ranks = RankMap::new(art.width(), art.height());
ranks.set(0, 0, 0.0);
ranks.set(4, 0, 1.0);
assert_eq!(
row_bytes(&art, &ranks, &Style::monochrome(), 0.5, 0, 80),
"#"
);
}
#[test]
fn colour_runs_are_coalesced() {
let art = Art::parse("####");
let ranks = Geodesic::default().rank(&art);
let style = Style {
feather: 0.0, depth: ColorDepth::TrueColor,
..Style::default()
};
let out = row_bytes(&art, &ranks, &style, 1.0, 0, 80);
assert_eq!(
out.matches("\x1b[38;2;").count(),
1,
"one escape should cover the whole run: {out:?}"
);
assert!(out.ends_with(FG_RESET), "run must be closed: {out:?}");
}
#[test]
fn depth_maps_onto_the_available_palette() {
assert_eq!(ColorDepth::Mono.quantize((255, 0, 0)), None);
assert_eq!(
ColorDepth::TrueColor.quantize((1, 2, 3)),
Some(Fg::Rgb(1, 2, 3))
);
assert_eq!(
ColorDepth::Ansi16.quantize((250, 10, 10)),
Some(Fg::Basic(9))
);
assert_eq!(
ColorDepth::Ansi256.quantize((0, 0, 0)),
Some(Fg::Indexed(232))
);
assert_eq!(
ColorDepth::Ansi256.quantize((255, 255, 255)),
Some(Fg::Indexed(255))
);
assert_eq!(
ColorDepth::Ansi256.quantize((255, 0, 0)),
Some(Fg::Indexed(16 + 36 * 5))
);
}
#[test]
fn foreground_escapes_are_well_formed() {
assert_eq!(Fg::Rgb(1, 2, 3).to_string(), "\x1b[38;2;1;2;3m");
assert_eq!(Fg::Indexed(200).to_string(), "\x1b[38;5;200m");
assert_eq!(Fg::Basic(3).to_string(), "\x1b[33m");
assert_eq!(Fg::Basic(9).to_string(), "\x1b[91m");
assert_eq!(FG_RESET, "\x1b[39m");
}
#[test]
fn viewport_fit_clips_oversized_art() {
let art = Art::parse(&"##########\n".repeat(10));
let viewport = Viewport { cols: 4, rows: 3 };
let fit = viewport.fit(&art, 1);
assert_eq!(fit.cols, 4, "clipped, not wrapped");
assert_eq!(fit.rows, 2, "one row reserved for the caption");
assert_eq!((fit.ox, fit.oy), (0, 0));
}
#[test]
fn viewport_fit_centres_small_art() {
let art = Art::parse("##");
let fit = Viewport { cols: 10, rows: 10 }.fit(&art, 0);
assert_eq!(fit.ox, 4);
assert_eq!(fit.cols, 2);
}
#[test]
fn light_style_is_darker_than_the_default() {
let sum = |(r, g, b): (u8, u8, u8)| r as u32 + g as u32 + b as u32;
assert!(sum(Style::light().body) < sum(Style::default().body));
assert!(sum(Style::light().head) < sum(Style::default().head));
}
#[cfg(feature = "unicode")]
#[test]
fn display_width_counts_wide_glyphs() {
use crate::width::glyph_cols;
assert_eq!(glyph_cols('a'), 1);
assert_eq!(glyph_cols('世'), 2);
let art = Art::parse("a世\nbb"); assert_eq!(art_cols(&art), 3);
}
}