Skip to main content

inkling/
render.rs

1//! Terminal renderer: an optimised, colourful superset of [`crate::frame`].
2//!
3//! The unit of rendering is a [`Reveal`] session: construct it, push a new
4//! `progress` value whenever your task advances, and finish. Each call diffs
5//! against the previous frame, so only cells whose appearance changed are
6//! repainted, in practice the moving "frontier" band plus whatever ink just
7//! settled. Settled cells are painted exactly once.
8//!
9//! The glowing frontier is not an effect bolted on; it falls out of the model. A
10//! cell `feather` rank-units behind `progress` is at the frontier; one further
11//! behind has settled. Colour is interpolated across that band, so the bright
12//! "head" of the reveal slides along the spine for free.
13
14use std::io::{self, IsTerminal, Write};
15use std::time::{Duration, Instant};
16
17use crossterm::{
18    cursor::{Hide, MoveTo, Show},
19    execute, queue,
20    style::{Color, Print, ResetColor, SetForegroundColor},
21    terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
22};
23
24use crate::{art::Art, easing::Easing, rank::RankMap};
25
26/// Number of quantised brightness steps across the frontier. The frontier band
27/// repaints as it moves; level `GLOW_LEVELS` is "settled" and paints just once.
28const GLOW_LEVELS: u8 = 8;
29
30/// DEC private mode 2026, *synchronized output*. A terminal that understands it
31/// buffers everything between begin and end and presents the frame as one atomic
32/// update, so a reveal never tears mid-paint; terminals that do not recognise the
33/// mode silently ignore both markers, so it is always safe to emit.
34pub(crate) const SYNC_BEGIN: &str = "\x1b[?2026h";
35pub(crate) const SYNC_END: &str = "\x1b[?2026l";
36
37/// How revealed ink is coloured.
38#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
39pub enum Palette {
40    /// A warm frontier glow: bright `head` at the leading edge easing to `body`.
41    #[default]
42    Glow,
43    /// A position-based rainbow, in the spirit of `lolcat`.
44    Rainbow,
45}
46
47/// Visual options for the reveal.
48#[derive(Clone, Copy, Debug)]
49pub struct Style {
50    /// Width of the soft leading edge, in rank units. The band of cells within
51    /// `feather` of the frontier is the glowing "head". `0.0` disables the glow.
52    pub feather: f32,
53    /// Colour of settled (fully revealed) ink, under the `Glow` palette.
54    pub body: (u8, u8, u8),
55    /// Colour at the very frontier, blended toward `body` across the feather.
56    pub head: (u8, u8, u8),
57    /// Emit colour. Defaults off when `NO_COLOR` is set.
58    pub color: bool,
59    /// How revealed cells are coloured.
60    pub palette: Palette,
61}
62
63impl Default for Style {
64    fn default() -> Self {
65        Style {
66            feather: 0.07,
67            body: (120, 134, 168),
68            head: (255, 226, 138),
69            color: std::env::var_os("NO_COLOR").is_none(),
70            palette: Palette::Glow,
71        }
72    }
73}
74
75impl Style {
76    /// A rainbow palette in the spirit of `lolcat`: each glyph takes its hue from
77    /// its position, so the art reveals in diagonal bands of colour.
78    pub fn rainbow() -> Self {
79        Style {
80            palette: Palette::Rainbow,
81            ..Style::default()
82        }
83    }
84}
85
86/// Per-cell visual state, used for frame diffing.
87#[derive(Clone, Copy, PartialEq, Eq)]
88enum CellState {
89    Hidden,
90    /// Lit at a quantised brightness `0..=GLOW_LEVELS` (`GLOW_LEVELS` == settled).
91    Lit(u8),
92}
93
94/// A live terminal reveal session.
95///
96/// Construct it, call [`render`](Reveal::render) with each new progress value as
97/// your task advances, then [`finish`](Reveal::finish). The terminal is restored
98/// on drop even if you forget, and everything degrades to a no-op when stdout is
99/// not a TTY (piped, redirected, CI), so the same code is safe everywhere.
100///
101/// Progress may move backwards as well as forwards; the reveal is seekable.
102///
103/// ```no_run
104/// use inkling::{Art, ordering::{Ordering, Geodesic}, render::{Reveal, Style}};
105///
106/// let art = Art::parse(include_str!("../assets/dragon.txt"));
107/// let ranks = Geodesic::default().rank(&art);
108///
109/// let mut reveal = Reveal::new(&art, &ranks, Style::default())?;
110/// for done in 0..=100 {
111///     reveal.render(done as f32 / 100.0)?;
112///     // ... do a slice of real work ...
113/// }
114/// reveal.finish()?;
115/// # Ok::<(), std::io::Error>(())
116/// ```
117pub struct Reveal<'a> {
118    art: &'a Art,
119    ranks: &'a RankMap,
120    style: Style,
121    state: Vec<CellState>,
122    out: io::Stdout,
123    origin: (u16, u16),
124    /// Whether we entered the alternate screen (true only on a TTY, until finished).
125    active: bool,
126}
127
128impl<'a> Reveal<'a> {
129    /// Begin a reveal session. On a TTY this switches to the alternate screen and
130    /// hides the cursor; otherwise it is inert until [`finish`](Reveal::finish).
131    pub fn new(art: &'a Art, ranks: &'a RankMap, style: Style) -> io::Result<Self> {
132        let mut out = io::stdout();
133        let active = out.is_terminal();
134        let origin = if active {
135            let (cols, _) = terminal::size().unwrap_or((art.width(), art.height()));
136            (cols.saturating_sub(art_cols(art)) / 2, 1)
137        } else {
138            (0, 0)
139        };
140        if active {
141            execute!(out, EnterAlternateScreen, Hide, Clear(ClearType::All))?;
142        }
143        Ok(Reveal {
144            art,
145            ranks,
146            style,
147            state: vec![CellState::Hidden; art.cell_count()],
148            out,
149            origin,
150            active,
151        })
152    }
153
154    /// Render the frame at `progress`. A no-op when stdout is not a TTY.
155    pub fn render(&mut self, progress: f32) -> io::Result<()> {
156        if !self.active {
157            return Ok(());
158        }
159        paint(
160            &mut self.out,
161            self.art,
162            self.ranks,
163            &self.style,
164            &mut self.state,
165            progress,
166            self.origin,
167        )
168    }
169
170    /// Restore the terminal and leave the completed art in normal scrollback.
171    pub fn finish(mut self) -> io::Result<()> {
172        self.restore()?;
173        write!(
174            self.out,
175            "{}",
176            crate::frame::to_string(self.art, self.ranks, 1.0)
177        )?;
178        self.out.flush()
179    }
180
181    fn restore(&mut self) -> io::Result<()> {
182        if self.active {
183            self.active = false;
184            execute!(self.out, ResetColor, Show, LeaveAlternateScreen)?;
185        }
186        Ok(())
187    }
188}
189
190impl Drop for Reveal<'_> {
191    fn drop(&mut self) {
192        let _ = self.restore();
193    }
194}
195
196/// Animate the reveal of `art` over `duration`, driven by `easing`.
197///
198/// A convenience driver built on [`Reveal`] for demos and indeterminate waits.
199/// When stdout is not a TTY it prints the final frame once and returns.
200pub fn animate(
201    art: &Art,
202    ranks: &RankMap,
203    style: Style,
204    duration: Duration,
205    easing: Easing,
206) -> io::Result<()> {
207    if !io::stdout().is_terminal() {
208        print!("{}", crate::frame::to_string(art, ranks, 1.0));
209        return Ok(());
210    }
211
212    let mut reveal = Reveal::new(art, ranks, style)?;
213    let total = duration.as_secs_f32().max(0.001);
214    let frame = Duration::from_millis(16); // ~60 fps
215    let start = Instant::now();
216
217    for tick in 1.. {
218        let t = (start.elapsed().as_secs_f32() / total).min(1.0);
219        reveal.render(easing.apply(t))?;
220        if t >= 1.0 {
221            break;
222        }
223        // Sleep until the next tick boundary so pacing does not drift with the
224        // time spent painting.
225        if let Some(remaining) = (start + frame * tick).checked_duration_since(Instant::now()) {
226            std::thread::sleep(remaining);
227        }
228    }
229    reveal.finish()
230}
231
232/// Diff `progress`'s frame against `state` and repaint only the cells that moved.
233fn paint(
234    out: &mut io::Stdout,
235    art: &Art,
236    ranks: &RankMap,
237    style: &Style,
238    state: &mut [CellState],
239    progress: f32,
240    (ox, oy): (u16, u16),
241) -> io::Result<()> {
242    let mut dirty = false;
243    for y in 0..art.height() {
244        let mut col = 0u16; // display column, so wide glyphs stay aligned
245        for x in 0..art.width() {
246            let glyph = art.glyph(x, y);
247            let cw = glyph_cols(glyph);
248            let idx = art.index(x, y);
249            let target = match ranks.rank_at(x, y) {
250                Some(r) if r <= progress => {
251                    let level = match style.palette {
252                        // A rainbow cell's colour is fixed by position, so it
253                        // settles immediately and never needs a frontier repaint.
254                        Palette::Rainbow => GLOW_LEVELS,
255                        Palette::Glow if style.feather <= 0.0 => GLOW_LEVELS,
256                        Palette::Glow => {
257                            let a = ((progress - r) / style.feather).clamp(0.0, 1.0);
258                            (a * GLOW_LEVELS as f32).round() as u8
259                        }
260                    };
261                    CellState::Lit(level)
262                }
263                _ => CellState::Hidden,
264            };
265
266            if state[idx] != target {
267                if !dirty {
268                    queue!(out, Print(SYNC_BEGIN))?;
269                }
270                queue!(out, MoveTo(ox + col, oy + y))?;
271                match target {
272                    // Clear across the glyph's full display width so a hidden wide
273                    // cell never leaves a stray half-column behind.
274                    CellState::Hidden => {
275                        for _ in 0..cw {
276                            queue!(out, Print(' '))?;
277                        }
278                    }
279                    CellState::Lit(level) => {
280                        if style.color {
281                            let (r, g, b) = match style.palette {
282                                Palette::Rainbow => rainbow_rgb(x, y, 0.0),
283                                Palette::Glow => {
284                                    blend(style.head, style.body, level as f32 / GLOW_LEVELS as f32)
285                                }
286                            };
287                            queue!(out, SetForegroundColor(Color::Rgb { r, g, b }))?;
288                        }
289                        queue!(out, Print(glyph))?;
290                    }
291                }
292                state[idx] = target;
293                dirty = true;
294            }
295            col += cw;
296        }
297    }
298
299    if dirty {
300        queue!(out, ResetColor, Print(SYNC_END))?;
301        out.flush()?;
302    }
303    Ok(())
304}
305
306/// Linear interpolation between two RGB colours; `s == 0` yields `a`, `s == 1` yields `b`.
307fn blend(a: (u8, u8, u8), b: (u8, u8, u8), s: f32) -> (u8, u8, u8) {
308    let lerp = |x: u8, y: u8| {
309        (x as f32 + (y as f32 - x as f32) * s)
310            .round()
311            .clamp(0.0, 255.0) as u8
312    };
313    (lerp(a.0, b.0), lerp(a.1, b.1), lerp(a.2, b.2))
314}
315
316/// The colour an ink cell shows at `progress`: `head` at the frontier, easing to
317/// `body` once it has settled `feather` behind. Shared with the loader renderer.
318pub(crate) fn frontier_rgb(style: &Style, progress: f32, rank: f32) -> (u8, u8, u8) {
319    if style.feather <= 0.0 {
320        return style.body;
321    }
322    let a = ((progress - rank) / style.feather).clamp(0.0, 1.0);
323    blend(style.head, style.body, a)
324}
325
326/// The colour of a revealed cell, honouring the style's palette. `t` is elapsed
327/// seconds, which animates the rainbow; pass `0.0` for a still frame.
328pub(crate) fn cell_rgb(
329    style: &Style,
330    progress: f32,
331    rank: f32,
332    x: u16,
333    y: u16,
334    t: f32,
335) -> (u8, u8, u8) {
336    match style.palette {
337        Palette::Glow => frontier_rgb(style, progress, rank),
338        Palette::Rainbow => rainbow_rgb(x, y, t),
339    }
340}
341
342/// A `lolcat` style hue from a cell's position, drifting over time.
343fn rainbow_rgb(x: u16, y: u16, t: f32) -> (u8, u8, u8) {
344    let hue = (x as f32 * 0.05 + y as f32 * 0.12 + t * 0.4).rem_euclid(1.0);
345    hsl_to_rgb(hue, 0.95, 0.62)
346}
347
348/// HSL to RGB, with hue in `0..1`.
349fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
350    let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
351    let hp = h * 6.0;
352    let x = c * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs());
353    let (r, g, b) = match hp as u32 {
354        0 => (c, x, 0.0),
355        1 => (x, c, 0.0),
356        2 => (0.0, c, x),
357        3 => (0.0, x, c),
358        4 => (x, 0.0, c),
359        _ => (c, 0.0, x),
360    };
361    let m = l - c / 2.0;
362    let to = |v: f32| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
363    (to(r), to(g), to(b))
364}
365
366/// Display columns a glyph occupies: 0 for zero-width or combining marks, 2 for
367/// wide glyphs (CJK and many emoji), 1 otherwise. Keeps the reveal aligned when
368/// the art is not pure ASCII.
369pub(crate) fn glyph_cols(c: char) -> u16 {
370    unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16
371}
372
373/// The widest row of `art`, in display columns.
374pub(crate) fn art_cols(art: &Art) -> u16 {
375    (0..art.height())
376        .map(|y| {
377            (0..art.width())
378                .map(|x| glyph_cols(art.glyph(x, y)))
379                .sum::<u16>()
380        })
381        .max()
382        .unwrap_or(0)
383}
384
385/// Truncate `s` to at most `max` display columns, dropping whole glyphs so a wide
386/// glyph is never split across the edge.
387pub(crate) fn truncate_to_cols(s: &str, max: u16) -> String {
388    let mut out = String::new();
389    let mut used = 0u16;
390    for c in s.chars() {
391        let w = glyph_cols(c);
392        if used + w > max {
393            break;
394        }
395        out.push(c);
396        used += w;
397    }
398    out
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn display_width_counts_wide_glyphs() {
407        assert_eq!(glyph_cols('a'), 1);
408        assert_eq!(glyph_cols('世'), 2);
409        let art = Art::parse("a世\nbb"); // row 0 is 1 + 2 = 3 columns wide
410        assert_eq!(art_cols(&art), 3);
411    }
412
413    #[test]
414    fn truncate_respects_display_width() {
415        assert_eq!(truncate_to_cols("abc", 2), "ab");
416        assert_eq!(truncate_to_cols("a世", 3), "a世"); // 1 + 2 == 3 fits
417        assert_eq!(truncate_to_cols("世界", 3), "世"); // 2 + 2 > 3, drop the second
418    }
419}