Skip to main content

inkling/
render.rs

1//! Terminal rendering.
2//!
3//! Two renderers live here and they share one walk. `queue_row` writes a whole
4//! row of the reveal, honouring display width, run-length colour, colour depth,
5//! and a column budget; the live loader draws every frame through it. [`Reveal`]
6//! is the diffing session for frame-by-frame control: it makes the same per-cell
7//! decision via [`crate::frame::cell`] but repaints only the cells that moved, in
8//! practice the frontier band plus whatever ink just settled.
9//!
10//! The glowing frontier is not an effect bolted on; it falls out of the model. A
11//! cell `feather` rank-units behind `progress` is at the frontier; one further
12//! behind has settled. Colour is interpolated across that band, so the bright
13//! "head" of the reveal slides along the spine for free.
14
15use std::io::{self, IsTerminal, Write};
16use std::time::{Duration, Instant};
17
18use crossterm::{
19    cursor::{Hide, MoveTo, Show},
20    execute, queue,
21    style::{Print, ResetColor},
22    terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
23};
24
25use crate::frame::{self, Paint};
26use crate::{art::Art, easing::Easing, guard, rank::RankMap};
27
28/// Number of quantised brightness steps across the frontier. The frontier band
29/// repaints as it moves; level `GLOW_LEVELS` is "settled" and paints just once.
30const GLOW_LEVELS: u8 = 8;
31
32/// DEC private mode 2026, *synchronized output*. A terminal that understands it
33/// buffers everything between begin and end and presents the frame as one atomic
34/// update, so a reveal never tears mid-paint; terminals that do not recognise the
35/// mode silently ignore both markers, so it is always safe to emit.
36pub(crate) const SYNC_BEGIN: &str = "\x1b[?2026h";
37pub(crate) const SYNC_END: &str = "\x1b[?2026l";
38
39// ---------------------------------------------------------------------------
40// Colour depth
41// ---------------------------------------------------------------------------
42
43/// How many colours the output can carry.
44///
45/// The frontier glow is a gradient, so it is the part of the design that suffers
46/// most on a limited terminal. Rather than emit 24-bit escapes everywhere and let
47/// the terminal do something arbitrary, the palette is mapped down explicitly.
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub enum ColorDepth {
50    /// No colour at all. Chosen when `NO_COLOR` is set or `TERM=dumb`.
51    Mono,
52    /// The 16 ANSI colours.
53    Ansi16,
54    /// The 256-colour cube. The default when the terminal does not say otherwise.
55    #[default]
56    Ansi256,
57    /// 24-bit colour, the full palette.
58    TrueColor,
59}
60
61impl ColorDepth {
62    /// What the current terminal advertises.
63    ///
64    /// Honours [`NO_COLOR`](https://no-color.org), then `COLORTERM`, then `TERM`,
65    /// and treats Windows Terminal as truecolor. Falls back to
66    /// [`Ansi256`](Self::Ansi256), which is near-universal and keeps the gradient
67    /// readable.
68    pub fn detect() -> Self {
69        let var = |k: &str| std::env::var(k).unwrap_or_default().to_ascii_lowercase();
70
71        if std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()) {
72            return ColorDepth::Mono;
73        }
74        let term = var("TERM");
75        if term == "dumb" {
76            return ColorDepth::Mono;
77        }
78        let colorterm = var("COLORTERM");
79        if colorterm.contains("truecolor") || colorterm.contains("24bit") {
80            return ColorDepth::TrueColor;
81        }
82        // Windows Terminal, and conhost on Windows 10 1703 and later, are 24-bit.
83        if std::env::var_os("WT_SESSION").is_some() || (cfg!(windows) && term.is_empty()) {
84            return ColorDepth::TrueColor;
85        }
86        if term.contains("256color") {
87            return ColorDepth::Ansi256;
88        }
89        if term.contains("16color") || term == "linux" {
90            return ColorDepth::Ansi16;
91        }
92        ColorDepth::Ansi256
93    }
94
95    /// True when anything at all should be coloured.
96    #[inline]
97    pub fn is_color(self) -> bool {
98        self != ColorDepth::Mono
99    }
100
101    /// Map an RGB triple onto the closest colour this depth can show.
102    pub fn quantize(self, (r, g, b): (u8, u8, u8)) -> Option<Fg> {
103        match self {
104            ColorDepth::Mono => None,
105            ColorDepth::TrueColor => Some(Fg::Rgb(r, g, b)),
106            ColorDepth::Ansi256 => Some(Fg::Indexed(ansi256(r, g, b))),
107            ColorDepth::Ansi16 => Some(Fg::Basic(ansi16(r, g, b))),
108        }
109    }
110}
111
112/// A foreground colour, resolved to whatever the terminal can show.
113///
114/// Written as its own SGR sequence rather than through a `crossterm` command:
115/// crossterm routes colour through the Win32 console API when virtual-terminal
116/// processing is unavailable, which cannot work when the sink is an arbitrary
117/// writer, and this crate is VT-only anyway (the alternate screen and DEC 2026
118/// synchronized output have no console-API equivalent). Emitting the bytes
119/// directly also makes every renderer testable against a plain `Vec<u8>`.
120#[derive(Clone, Copy, Debug, PartialEq, Eq)]
121pub enum Fg {
122    /// 24-bit colour.
123    Rgb(u8, u8, u8),
124    /// An index into the 256-colour palette.
125    Indexed(u8),
126    /// One of the 16 basic colours.
127    Basic(u8),
128}
129
130/// Return the foreground to the terminal's default without disturbing any other
131/// attribute the surrounding program may have set.
132pub(crate) const FG_RESET: &str = "\x1b[39m";
133
134impl std::fmt::Display for Fg {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        match *self {
137            Fg::Rgb(r, g, b) => write!(f, "\x1b[38;2;{r};{g};{b}m"),
138            Fg::Indexed(i) => write!(f, "\x1b[38;5;{i}m"),
139            // 30..=37 for the first eight, 90..=97 for the bright half.
140            Fg::Basic(i @ 0..=7) => write!(f, "\x1b[{}m", 30 + i as u16),
141            Fg::Basic(i) => write!(f, "\x1b[{}m", 90 + (i.min(15) - 8) as u16),
142        }
143    }
144}
145
146/// Nearest index in the xterm 256-colour palette: the 6x6x6 cube, or the 24-step
147/// grey ramp when the channels are close enough to neutral for it to be a better
148/// match (the ramp is much finer than the cube's 51-unit spacing).
149fn ansi256(r: u8, g: u8, b: u8) -> u8 {
150    let (lo, hi) = (r.min(g).min(b) as i32, r.max(g).max(b) as i32);
151    if hi - lo < 12 {
152        let level = ((r as i32 + g as i32 + b as i32) / 3 - 8).clamp(0, 238);
153        return 232 + (level * 23 / 238) as u8;
154    }
155    // The cube's levels are 0, 95, 135, 175, 215, 255: not evenly spaced, so map
156    // through the same steps xterm uses rather than dividing by 51.
157    let step = |v: u8| -> u8 {
158        match v {
159            0..=47 => 0,
160            48..=114 => 1,
161            115..=154 => 2,
162            155..=194 => 3,
163            195..=234 => 4,
164            _ => 5,
165        }
166    };
167    16 + 36 * step(r) + 6 * step(g) + step(b)
168}
169
170/// Nearest of the 16 ANSI colours, by squared distance in RGB.
171fn ansi16(r: u8, g: u8, b: u8) -> u8 {
172    const PALETTE: [(u8, u8, u8); 16] = [
173        (0, 0, 0),
174        (128, 0, 0),
175        (0, 128, 0),
176        (128, 128, 0),
177        (0, 0, 128),
178        (128, 0, 128),
179        (0, 128, 128),
180        (192, 192, 192),
181        (128, 128, 128),
182        (255, 0, 0),
183        (0, 255, 0),
184        (255, 255, 0),
185        (0, 0, 255),
186        (255, 0, 255),
187        (0, 255, 255),
188        (255, 255, 255),
189    ];
190    let dist = |&(pr, pg, pb): &(u8, u8, u8)| {
191        let d = |a: u8, b: u8| (a as i32 - b as i32).pow(2);
192        d(pr, r) + d(pg, g) + d(pb, b)
193    };
194    PALETTE
195        .iter()
196        .enumerate()
197        .min_by_key(|(_, c)| dist(c))
198        .map(|(i, _)| i as u8)
199        .unwrap_or(7)
200}
201
202// ---------------------------------------------------------------------------
203// Style
204// ---------------------------------------------------------------------------
205
206/// How revealed ink is coloured.
207#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
208pub enum Palette {
209    /// A warm frontier glow: bright `head` at the leading edge easing to `body`.
210    #[default]
211    Glow,
212    /// A position-based rainbow, in the spirit of `lolcat`.
213    Rainbow,
214}
215
216/// Visual options for the reveal.
217#[derive(Clone, Copy, Debug)]
218pub struct Style {
219    /// Width of the soft leading edge, in rank units. The band of cells within
220    /// `feather` of the frontier is the glowing "head". `0.0` disables the glow.
221    pub feather: f32,
222    /// Colour of settled (fully revealed) ink, under the `Glow` palette.
223    pub body: (u8, u8, u8),
224    /// Colour at the very frontier, blended toward `body` across the feather.
225    pub head: (u8, u8, u8),
226    /// Colour resolution to emit. Defaults to what the terminal advertises, and
227    /// to [`ColorDepth::Mono`] when `NO_COLOR` is set.
228    pub depth: ColorDepth,
229    /// How revealed cells are coloured.
230    pub palette: Palette,
231    /// Caption colour, for the line beneath the art.
232    pub caption: (u8, u8, u8),
233}
234
235impl Default for Style {
236    /// The dark-terminal palette: cool slate ink with a warm gold leading edge.
237    fn default() -> Self {
238        Style {
239            feather: 0.07,
240            body: (120, 134, 168),
241            head: (255, 226, 138),
242            depth: ColorDepth::detect(),
243            palette: Palette::Glow,
244            caption: (120, 134, 168),
245        }
246    }
247}
248
249impl Style {
250    /// A rainbow palette in the spirit of `lolcat`: each glyph takes its hue from
251    /// its position, so the art reveals in diagonal bands of colour.
252    pub fn rainbow() -> Self {
253        Style {
254            palette: Palette::Rainbow,
255            ..Style::default()
256        }
257    }
258
259    /// Tuned for a light terminal background.
260    ///
261    /// The default palette assumes a dark ground: its slate body sits at roughly
262    /// 3:1 against white, below the readable threshold, and the gold frontier all
263    /// but vanishes. This darkens both ends and keeps the same warm-cool
264    /// relationship.
265    pub fn light() -> Self {
266        Style {
267            body: (72, 84, 112),
268            head: (176, 106, 12),
269            caption: (96, 106, 130),
270            ..Style::default()
271        }
272    }
273
274    /// No colour: glyphs only. What `NO_COLOR` selects.
275    pub fn monochrome() -> Self {
276        Style {
277            depth: ColorDepth::Mono,
278            ..Style::default()
279        }
280    }
281
282    /// True when this style emits any colour at all.
283    #[inline]
284    pub fn is_color(&self) -> bool {
285        self.depth.is_color()
286    }
287}
288
289// ---------------------------------------------------------------------------
290// Colour of a single cell
291// ---------------------------------------------------------------------------
292
293/// Linear interpolation between two RGB colours; `s == 0` yields `a`, `s == 1` yields `b`.
294fn blend(a: (u8, u8, u8), b: (u8, u8, u8), s: f32) -> (u8, u8, u8) {
295    let lerp = |x: u8, y: u8| {
296        (x as f32 + (y as f32 - x as f32) * s)
297            .round()
298            .clamp(0.0, 255.0) as u8
299    };
300    (lerp(a.0, b.0), lerp(a.1, b.1), lerp(a.2, b.2))
301}
302
303/// How far behind the frontier a cell sits, `0.0` at the leading edge and `1.0`
304/// once settled.
305#[inline]
306fn settle(style: &Style, progress: f32, rank: f32) -> f32 {
307    if style.feather <= 0.0 {
308        1.0
309    } else {
310        ((progress - rank) / style.feather).clamp(0.0, 1.0)
311    }
312}
313
314/// The colour an ink cell shows at `progress`: `head` at the frontier, easing to
315/// `body` once it has settled `feather` behind.
316pub(crate) fn frontier_rgb(style: &Style, progress: f32, rank: f32) -> (u8, u8, u8) {
317    blend(style.head, style.body, settle(style, progress, rank))
318}
319
320/// The colour of a revealed cell, honouring the style's palette. `t` is elapsed
321/// seconds, which animates the rainbow; pass `0.0` for a still frame.
322pub(crate) fn cell_rgb(
323    style: &Style,
324    progress: f32,
325    rank: f32,
326    x: u16,
327    y: u16,
328    t: f32,
329) -> (u8, u8, u8) {
330    match style.palette {
331        Palette::Glow => frontier_rgb(style, progress, rank),
332        Palette::Rainbow => rainbow_rgb(x, y, t),
333    }
334}
335
336/// A `lolcat` style hue from a cell's position, drifting over time.
337fn rainbow_rgb(x: u16, y: u16, t: f32) -> (u8, u8, u8) {
338    let hue = (x as f32 * 0.05 + y as f32 * 0.12 + t * 0.4).rem_euclid(1.0);
339    hsl_to_rgb(hue, 0.95, 0.62)
340}
341
342/// HSL to RGB, with hue in `0..1`.
343fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
344    let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
345    let hp = h * 6.0;
346    let x = c * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs());
347    let (r, g, b) = match hp as u32 {
348        0 => (c, x, 0.0),
349        1 => (x, c, 0.0),
350        2 => (0.0, c, x),
351        3 => (0.0, x, c),
352        4 => (x, 0.0, c),
353        _ => (c, 0.0, x),
354    };
355    let m = l - c / 2.0;
356    let to = |v: f32| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
357    (to(r), to(g), to(b))
358}
359
360// ---------------------------------------------------------------------------
361// The shared row writer
362// ---------------------------------------------------------------------------
363
364/// What is being drawn: the picture, its ranks, and how it is coloured.
365///
366/// These three always travel together, in every renderer, and are fixed for the
367/// life of a reveal. Naming the triple keeps the row writer's signature about the
368/// row rather than about plumbing.
369#[derive(Clone, Copy)]
370pub(crate) struct Scene<'a> {
371    pub art: &'a Art,
372    pub ranks: &'a RankMap,
373    pub style: &'a Style,
374}
375
376/// Write row `y` of the reveal at `progress` into `out`.
377///
378/// This is the one place a row of the reveal is turned into bytes. It clips at
379/// `budget` display columns without ever splitting a wide glyph, coalesces runs
380/// of one colour into a single escape, and trims trailing blanks (so callers
381/// clear the line first when a previous frame may have left ink there).
382///
383/// `t` is elapsed seconds, which animates the rainbow palette.
384pub(crate) fn queue_row<W: Write>(
385    out: &mut W,
386    scene: Scene<'_>,
387    progress: f32,
388    t: f32,
389    y: u16,
390    budget: u16,
391) -> io::Result<()> {
392    let Scene { art, ranks, style } = scene;
393    // Collect first so trailing blanks can be dropped rather than emitted.
394    let mut cells: Vec<(u16, Paint)> = Vec::with_capacity(art.width() as usize);
395    for (x, at, paint) in frame::row(art, ranks, progress, y) {
396        if at.saturating_add(paint.cols()) > budget {
397            break;
398        }
399        cells.push((x, paint));
400    }
401    while matches!(cells.last(), Some((_, Paint::Blank { .. }))) {
402        cells.pop();
403    }
404
405    let mut current: Option<Fg> = None;
406    for (x, paint) in cells {
407        match paint {
408            Paint::Blank { cols } => {
409                // Blanks carry no colour, so drop out of the current run rather
410                // than painting a background nobody asked for.
411                if current.take().is_some() {
412                    write!(out, "{FG_RESET}")?;
413                }
414                for _ in 0..cols {
415                    out.write_all(b" ")?;
416                }
417            }
418            Paint::Ink { glyph, .. } => {
419                let color = ranks
420                    .rank_at(x, y)
421                    .and_then(|r| style.depth.quantize(cell_rgb(style, progress, r, x, y, t)));
422                if color != current {
423                    match color {
424                        Some(c) => write!(out, "{c}")?,
425                        None => write!(out, "{FG_RESET}")?,
426                    }
427                    current = color;
428                }
429                write!(out, "{glyph}")?;
430            }
431        }
432    }
433    if current.is_some() {
434        write!(out, "{FG_RESET}")?;
435    }
436    Ok(())
437}
438
439/// The area available to draw in, in display columns and rows.
440#[derive(Clone, Copy, Debug, PartialEq, Eq)]
441pub(crate) struct Viewport {
442    pub cols: u16,
443    pub rows: u16,
444}
445
446impl Viewport {
447    /// The terminal's current size, or a conservative 80x24 if it cannot be read.
448    pub fn detect() -> Self {
449        let (cols, rows) = terminal::size().unwrap_or((80, 24));
450        Viewport {
451            cols: cols.max(1),
452            rows: rows.max(1),
453        }
454    }
455
456    /// Where to place `art` so it is centred, and how much of it fits. Art larger
457    /// than the viewport is anchored at the edge and clipped rather than allowed
458    /// to wrap, which would desynchronise every subsequent frame.
459    pub fn fit(&self, art: &Art, reserve_rows: u16) -> Fit {
460        let art_w = frame::art_cols(art);
461        let usable_rows = self.rows.saturating_sub(reserve_rows);
462        Fit {
463            ox: self.cols.saturating_sub(art_w) / 2,
464            oy: usable_rows.saturating_sub(art.height()) / 2,
465            cols: art_w.min(self.cols),
466            rows: art.height().min(usable_rows),
467        }
468    }
469}
470
471/// Where a piece of art sits in the viewport, and how much of it is visible.
472#[derive(Clone, Copy, Debug, PartialEq, Eq)]
473pub(crate) struct Fit {
474    pub ox: u16,
475    pub oy: u16,
476    pub cols: u16,
477    pub rows: u16,
478}
479
480// ---------------------------------------------------------------------------
481// Reveal: the diffing session
482// ---------------------------------------------------------------------------
483
484/// Per-cell visual state, used for frame diffing.
485#[derive(Clone, Copy, PartialEq, Eq)]
486enum CellState {
487    Hidden,
488    /// Lit at a quantised brightness `0..=GLOW_LEVELS` (`GLOW_LEVELS` == settled).
489    Lit(u8),
490}
491
492/// A live terminal reveal session.
493///
494/// Construct it, call [`render`](Reveal::render) with each new progress value as
495/// your task advances, then [`finish`](Reveal::finish). The terminal is restored
496/// on drop, on panic, and on Ctrl+C, and everything degrades to a no-op when
497/// stdout is not a TTY (piped, redirected, CI), so the same code is safe
498/// everywhere.
499///
500/// Progress may move backwards as well as forwards; the reveal is seekable.
501///
502/// ```no_run
503/// use inkling::{Art, ordering::{Ordering, Geodesic}, render::{Reveal, Style}};
504///
505/// let art = Art::parse(include_str!("../assets/dragon.txt"));
506/// let ranks = Geodesic::default().rank(&art);
507///
508/// let mut reveal = Reveal::new(&art, &ranks, Style::default())?;
509/// for done in 0..=100 {
510///     reveal.render(done as f32 / 100.0)?;
511///     // ... do a slice of real work ...
512/// }
513/// reveal.finish()?;
514/// # Ok::<(), std::io::Error>(())
515/// ```
516pub struct Reveal<'a> {
517    art: &'a Art,
518    ranks: &'a RankMap,
519    style: Style,
520    state: Vec<CellState>,
521    out: io::Stdout,
522    /// Viewport as of the last frame; a change means the terminal was resized.
523    viewport: Viewport,
524    fit: Fit,
525    /// Whether we entered the alternate screen (true only on a TTY, until finished).
526    active: bool,
527}
528
529impl<'a> Reveal<'a> {
530    /// Begin a reveal session. On a TTY this switches to the alternate screen and
531    /// hides the cursor; otherwise it is inert until [`finish`](Reveal::finish).
532    pub fn new(art: &'a Art, ranks: &'a RankMap, style: Style) -> io::Result<Self> {
533        let mut out = io::stdout();
534        let active = out.is_terminal();
535        let viewport = if active {
536            Viewport::detect()
537        } else {
538            Viewport { cols: 80, rows: 24 }
539        };
540        let fit = viewport.fit(art, 0);
541        if active {
542            guard::arm();
543            execute!(out, EnterAlternateScreen, Hide, Clear(ClearType::All))?;
544            guard::set_alt_screen(true);
545            guard::set_cursor_hidden(true);
546        }
547        Ok(Reveal {
548            art,
549            ranks,
550            style,
551            state: vec![CellState::Hidden; art.cell_count()],
552            out,
553            viewport,
554            fit,
555            active,
556        })
557    }
558
559    /// Render the frame at `progress`. A no-op when stdout is not a TTY.
560    pub fn render(&mut self, progress: f32) -> io::Result<()> {
561        if !self.active {
562            return Ok(());
563        }
564        // A resize invalidates every cached cell position, so start the diff over.
565        let viewport = Viewport::detect();
566        if viewport != self.viewport {
567            self.viewport = viewport;
568            self.fit = viewport.fit(self.art, 0);
569            self.state.fill(CellState::Hidden);
570            execute!(self.out, Clear(ClearType::All))?;
571        }
572        self.paint(progress)
573    }
574
575    /// Diff `progress`'s frame against the cached state and repaint only the cells
576    /// that moved.
577    fn paint(&mut self, progress: f32) -> io::Result<()> {
578        let (art, ranks, style, fit) = (self.art, self.ranks, &self.style, self.fit);
579        let mut dirty = false;
580
581        for y in 0..fit.rows {
582            for (x, at, cell) in frame::row(art, ranks, progress, y) {
583                if at.saturating_add(cell.cols()) > fit.cols {
584                    break;
585                }
586                let idx = art.index(x, y);
587                let target = match cell {
588                    Paint::Blank { .. } => CellState::Hidden,
589                    Paint::Ink { .. } => {
590                        let rank = ranks.rank_at(x, y).unwrap_or(0.0);
591                        let level = match style.palette {
592                            // A rainbow cell's colour is fixed by position, so it
593                            // settles immediately and never needs a frontier repaint.
594                            Palette::Rainbow => GLOW_LEVELS,
595                            Palette::Glow => {
596                                (settle(style, progress, rank) * GLOW_LEVELS as f32).round() as u8
597                            }
598                        };
599                        CellState::Lit(level)
600                    }
601                };
602
603                if self.state[idx] == target {
604                    continue;
605                }
606                if !dirty {
607                    queue!(self.out, Print(SYNC_BEGIN))?;
608                    dirty = true;
609                }
610                queue!(self.out, MoveTo(fit.ox + at, fit.oy + y))?;
611                match (target, cell) {
612                    // Clear across the glyph's full display width so a hidden wide
613                    // cell never leaves a stray half-column behind.
614                    (CellState::Hidden, _) => {
615                        for _ in 0..cell.cols() {
616                            queue!(self.out, Print(' '))?;
617                        }
618                    }
619                    (CellState::Lit(level), Paint::Ink { glyph, .. }) => {
620                        let rgb = match style.palette {
621                            Palette::Rainbow => rainbow_rgb(x, y, 0.0),
622                            Palette::Glow => {
623                                blend(style.head, style.body, level as f32 / GLOW_LEVELS as f32)
624                            }
625                        };
626                        if let Some(c) = style.depth.quantize(rgb) {
627                            write!(self.out, "{c}")?;
628                        }
629                        write!(self.out, "{glyph}")?;
630                    }
631                    (CellState::Lit(_), Paint::Blank { .. }) => unreachable!(),
632                }
633                self.state[idx] = target;
634            }
635        }
636
637        if dirty {
638            write!(self.out, "{FG_RESET}")?;
639            queue!(self.out, Print(SYNC_END))?;
640            self.out.flush()?;
641        }
642        Ok(())
643    }
644
645    /// Restore the terminal and leave the completed art in normal scrollback.
646    pub fn finish(mut self) -> io::Result<()> {
647        self.restore()?;
648        write!(self.out, "{}", frame::to_string(self.art, self.ranks, 1.0))?;
649        self.out.flush()
650    }
651
652    fn restore(&mut self) -> io::Result<()> {
653        if self.active {
654            self.active = false;
655            execute!(self.out, ResetColor, Show, LeaveAlternateScreen)?;
656            guard::set_alt_screen(false);
657            guard::set_cursor_hidden(false);
658        }
659        Ok(())
660    }
661}
662
663impl Drop for Reveal<'_> {
664    fn drop(&mut self) {
665        let _ = self.restore();
666    }
667}
668
669/// Animate the reveal of `art` over `duration`, driven by `easing`.
670///
671/// A convenience driver built on [`Reveal`] for demos and indeterminate waits.
672/// When stdout is not a TTY it prints the final frame once and returns.
673pub fn animate(
674    art: &Art,
675    ranks: &RankMap,
676    style: Style,
677    duration: Duration,
678    easing: Easing,
679) -> io::Result<()> {
680    if !io::stdout().is_terminal() {
681        print!("{}", frame::to_string(art, ranks, 1.0));
682        return Ok(());
683    }
684
685    let mut reveal = Reveal::new(art, ranks, style)?;
686    let total = duration.as_secs_f32().max(0.001);
687    let frame_time = Duration::from_millis(16); // ~60 fps
688    let start = Instant::now();
689
690    for tick in 1u32.. {
691        let t = (start.elapsed().as_secs_f32() / total).min(1.0);
692        reveal.render(easing.apply(t))?;
693        if t >= 1.0 {
694            break;
695        }
696        // Sleep until the next tick boundary so pacing does not drift with the
697        // time spent painting.
698        if let Some(remaining) = (start + frame_time * tick).checked_duration_since(Instant::now())
699        {
700            std::thread::sleep(remaining);
701        }
702    }
703    reveal.finish()
704}
705
706// Re-exported for the loader and kept public for callers measuring their own art.
707pub use crate::frame::art_cols;
708pub use crate::width::{glyph_cols as display_cols, truncate_to_cols};
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713    use crate::ordering::{Geodesic, Ordering};
714
715    fn row_bytes(
716        art: &Art,
717        ranks: &RankMap,
718        style: &Style,
719        progress: f32,
720        y: u16,
721        budget: u16,
722    ) -> String {
723        let mut buf: Vec<u8> = Vec::new();
724        let scene = Scene { art, ranks, style };
725        queue_row(&mut buf, scene, progress, 0.0, y, budget).unwrap();
726        String::from_utf8(buf).unwrap()
727    }
728
729    #[test]
730    fn monochrome_rows_carry_no_escapes() {
731        let art = Art::parse("####");
732        let ranks = Geodesic::default().rank(&art);
733        let out = row_bytes(&art, &ranks, &Style::monochrome(), 1.0, 0, 80);
734        assert_eq!(out, "####");
735    }
736
737    #[test]
738    fn rows_are_clipped_to_the_budget() {
739        let art = Art::parse("##########");
740        let ranks = Geodesic::default().rank(&art);
741        let out = row_bytes(&art, &ranks, &Style::monochrome(), 1.0, 0, 4);
742        assert_eq!(out, "####");
743    }
744
745    /// A wide glyph is dropped whole rather than split across the clip edge.
746    #[cfg(feature = "unicode")]
747    #[test]
748    fn clipping_never_splits_a_wide_glyph() {
749        let art = Art::parse("世界");
750        let ranks = Geodesic::default().rank(&art);
751        assert_eq!(
752            row_bytes(&art, &ranks, &Style::monochrome(), 1.0, 0, 3),
753            "世"
754        );
755        assert_eq!(
756            row_bytes(&art, &ranks, &Style::monochrome(), 1.0, 0, 4),
757            "世界"
758        );
759    }
760
761    /// A hidden wide glyph reserves both its columns, so revealed ink to its right
762    /// never slides sideways as the frontier passes.
763    #[cfg(feature = "unicode")]
764    #[test]
765    fn hidden_wide_glyphs_hold_their_columns() {
766        use crate::width::str_cols;
767        let art = Art::parse("世a");
768        let mut ranks = RankMap::new(art.width(), art.height());
769        ranks.set(0, 0, 1.0); // the wide glyph reveals last
770        ranks.set(1, 0, 0.0);
771        let early = row_bytes(&art, &ranks, &Style::monochrome(), 0.5, 0, 80);
772        assert_eq!(early, "  a", "hidden wide glyph must reserve two columns");
773        assert_eq!(str_cols(&early), 3);
774        assert_eq!(
775            str_cols(&row_bytes(&art, &ranks, &Style::monochrome(), 1.0, 0, 80)),
776            3
777        );
778    }
779
780    #[test]
781    fn trailing_blanks_are_trimmed() {
782        let art = Art::parse("#   #");
783        let mut ranks = RankMap::new(art.width(), art.height());
784        ranks.set(0, 0, 0.0);
785        ranks.set(4, 0, 1.0);
786        assert_eq!(
787            row_bytes(&art, &ranks, &Style::monochrome(), 0.5, 0, 80),
788            "#"
789        );
790    }
791
792    #[test]
793    fn colour_runs_are_coalesced() {
794        let art = Art::parse("####");
795        let ranks = Geodesic::default().rank(&art);
796        let style = Style {
797            feather: 0.0, // every settled cell is the same body colour
798            depth: ColorDepth::TrueColor,
799            ..Style::default()
800        };
801        let out = row_bytes(&art, &ranks, &style, 1.0, 0, 80);
802        assert_eq!(
803            out.matches("\x1b[38;2;").count(),
804            1,
805            "one escape should cover the whole run: {out:?}"
806        );
807        assert!(out.ends_with(FG_RESET), "run must be closed: {out:?}");
808    }
809
810    #[test]
811    fn depth_maps_onto_the_available_palette() {
812        assert_eq!(ColorDepth::Mono.quantize((255, 0, 0)), None);
813        assert_eq!(
814            ColorDepth::TrueColor.quantize((1, 2, 3)),
815            Some(Fg::Rgb(1, 2, 3))
816        );
817        assert_eq!(
818            ColorDepth::Ansi16.quantize((250, 10, 10)),
819            Some(Fg::Basic(9))
820        );
821        // Neutral triples take the 24-step grey ramp, which is far finer than the
822        // cube's 51-unit spacing: 232 is its black end and 255 its white one.
823        assert_eq!(
824            ColorDepth::Ansi256.quantize((0, 0, 0)),
825            Some(Fg::Indexed(232))
826        );
827        assert_eq!(
828            ColorDepth::Ansi256.quantize((255, 255, 255)),
829            Some(Fg::Indexed(255))
830        );
831        // A saturated triple goes to the 6x6x6 cube instead.
832        assert_eq!(
833            ColorDepth::Ansi256.quantize((255, 0, 0)),
834            Some(Fg::Indexed(16 + 36 * 5))
835        );
836    }
837
838    #[test]
839    fn foreground_escapes_are_well_formed() {
840        assert_eq!(Fg::Rgb(1, 2, 3).to_string(), "\x1b[38;2;1;2;3m");
841        assert_eq!(Fg::Indexed(200).to_string(), "\x1b[38;5;200m");
842        assert_eq!(Fg::Basic(3).to_string(), "\x1b[33m");
843        assert_eq!(Fg::Basic(9).to_string(), "\x1b[91m");
844        assert_eq!(FG_RESET, "\x1b[39m");
845    }
846
847    #[test]
848    fn viewport_fit_clips_oversized_art() {
849        let art = Art::parse(&"##########\n".repeat(10));
850        let viewport = Viewport { cols: 4, rows: 3 };
851        let fit = viewport.fit(&art, 1);
852        assert_eq!(fit.cols, 4, "clipped, not wrapped");
853        assert_eq!(fit.rows, 2, "one row reserved for the caption");
854        assert_eq!((fit.ox, fit.oy), (0, 0));
855    }
856
857    #[test]
858    fn viewport_fit_centres_small_art() {
859        let art = Art::parse("##");
860        let fit = Viewport { cols: 10, rows: 10 }.fit(&art, 0);
861        assert_eq!(fit.ox, 4);
862        assert_eq!(fit.cols, 2);
863    }
864
865    #[test]
866    fn light_style_is_darker_than_the_default() {
867        let sum = |(r, g, b): (u8, u8, u8)| r as u32 + g as u32 + b as u32;
868        assert!(sum(Style::light().body) < sum(Style::default().body));
869        assert!(sum(Style::light().head) < sum(Style::default().head));
870    }
871
872    #[cfg(feature = "unicode")]
873    #[test]
874    fn display_width_counts_wide_glyphs() {
875        use crate::width::glyph_cols;
876        assert_eq!(glyph_cols('a'), 1);
877        assert_eq!(glyph_cols('世'), 2);
878        let art = Art::parse("a世\nbb"); // row 0 is 1 + 2 = 3 columns wide
879        assert_eq!(art_cols(&art), 3);
880    }
881}