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/// How revealed ink is coloured.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
32pub enum Palette {
33    /// A warm frontier glow: bright `head` at the leading edge easing to `body`.
34    #[default]
35    Glow,
36    /// A position-based rainbow, in the spirit of `lolcat`.
37    Rainbow,
38}
39
40/// Visual options for the reveal.
41#[derive(Clone, Copy, Debug)]
42pub struct Style {
43    /// Width of the soft leading edge, in rank units. The band of cells within
44    /// `feather` of the frontier is the glowing "head". `0.0` disables the glow.
45    pub feather: f32,
46    /// Colour of settled (fully revealed) ink, under the `Glow` palette.
47    pub body: (u8, u8, u8),
48    /// Colour at the very frontier, blended toward `body` across the feather.
49    pub head: (u8, u8, u8),
50    /// Emit colour. Defaults off when `NO_COLOR` is set.
51    pub color: bool,
52    /// How revealed cells are coloured.
53    pub palette: Palette,
54}
55
56impl Default for Style {
57    fn default() -> Self {
58        Style {
59            feather: 0.07,
60            body: (120, 134, 168),
61            head: (255, 226, 138),
62            color: std::env::var_os("NO_COLOR").is_none(),
63            palette: Palette::Glow,
64        }
65    }
66}
67
68impl Style {
69    /// A rainbow palette in the spirit of `lolcat`: each glyph takes its hue from
70    /// its position, so the art reveals in diagonal bands of colour.
71    pub fn rainbow() -> Self {
72        Style {
73            palette: Palette::Rainbow,
74            ..Style::default()
75        }
76    }
77}
78
79/// Per-cell visual state, used for frame diffing.
80#[derive(Clone, Copy, PartialEq, Eq)]
81enum CellState {
82    Hidden,
83    /// Lit at a quantised brightness `0..=GLOW_LEVELS` (`GLOW_LEVELS` == settled).
84    Lit(u8),
85}
86
87/// A live terminal reveal session.
88///
89/// Construct it, call [`render`](Reveal::render) with each new progress value as
90/// your task advances, then [`finish`](Reveal::finish). The terminal is restored
91/// on drop even if you forget, and everything degrades to a no-op when stdout is
92/// not a TTY (piped, redirected, CI), so the same code is safe everywhere.
93///
94/// Progress may move backwards as well as forwards; the reveal is seekable.
95///
96/// ```no_run
97/// use inkling::{Art, ordering::{Ordering, Geodesic}, render::{Reveal, Style}};
98///
99/// let art = Art::parse(include_str!("../assets/dragon.txt"));
100/// let ranks = Geodesic::default().rank(&art);
101///
102/// let mut reveal = Reveal::new(&art, &ranks, Style::default())?;
103/// for done in 0..=100 {
104///     reveal.render(done as f32 / 100.0)?;
105///     // ... do a slice of real work ...
106/// }
107/// reveal.finish()?;
108/// # Ok::<(), std::io::Error>(())
109/// ```
110pub struct Reveal<'a> {
111    art: &'a Art,
112    ranks: &'a RankMap,
113    style: Style,
114    state: Vec<CellState>,
115    out: io::Stdout,
116    origin: (u16, u16),
117    /// Whether we entered the alternate screen (true only on a TTY, until finished).
118    active: bool,
119}
120
121impl<'a> Reveal<'a> {
122    /// Begin a reveal session. On a TTY this switches to the alternate screen and
123    /// hides the cursor; otherwise it is inert until [`finish`](Reveal::finish).
124    pub fn new(art: &'a Art, ranks: &'a RankMap, style: Style) -> io::Result<Self> {
125        let mut out = io::stdout();
126        let active = out.is_terminal();
127        let origin = if active {
128            let (cols, _) = terminal::size().unwrap_or((art.width(), art.height()));
129            (cols.saturating_sub(art.width()) / 2, 1)
130        } else {
131            (0, 0)
132        };
133        if active {
134            execute!(out, EnterAlternateScreen, Hide, Clear(ClearType::All))?;
135        }
136        Ok(Reveal {
137            art,
138            ranks,
139            style,
140            state: vec![CellState::Hidden; art.cell_count()],
141            out,
142            origin,
143            active,
144        })
145    }
146
147    /// Render the frame at `progress`. A no-op when stdout is not a TTY.
148    pub fn render(&mut self, progress: f32) -> io::Result<()> {
149        if !self.active {
150            return Ok(());
151        }
152        paint(
153            &mut self.out,
154            self.art,
155            self.ranks,
156            &self.style,
157            &mut self.state,
158            progress,
159            self.origin,
160        )
161    }
162
163    /// Restore the terminal and leave the completed art in normal scrollback.
164    pub fn finish(mut self) -> io::Result<()> {
165        self.restore()?;
166        write!(
167            self.out,
168            "{}",
169            crate::frame::to_string(self.art, self.ranks, 1.0)
170        )?;
171        self.out.flush()
172    }
173
174    fn restore(&mut self) -> io::Result<()> {
175        if self.active {
176            self.active = false;
177            execute!(self.out, ResetColor, Show, LeaveAlternateScreen)?;
178        }
179        Ok(())
180    }
181}
182
183impl Drop for Reveal<'_> {
184    fn drop(&mut self) {
185        let _ = self.restore();
186    }
187}
188
189/// Animate the reveal of `art` over `duration`, driven by `easing`.
190///
191/// A convenience driver built on [`Reveal`] for demos and indeterminate waits.
192/// When stdout is not a TTY it prints the final frame once and returns.
193pub fn animate(
194    art: &Art,
195    ranks: &RankMap,
196    style: Style,
197    duration: Duration,
198    easing: Easing,
199) -> io::Result<()> {
200    if !io::stdout().is_terminal() {
201        print!("{}", crate::frame::to_string(art, ranks, 1.0));
202        return Ok(());
203    }
204
205    let mut reveal = Reveal::new(art, ranks, style)?;
206    let total = duration.as_secs_f32().max(0.001);
207    let frame = Duration::from_millis(16); // ~60 fps
208    let start = Instant::now();
209
210    for tick in 1.. {
211        let t = (start.elapsed().as_secs_f32() / total).min(1.0);
212        reveal.render(easing.apply(t))?;
213        if t >= 1.0 {
214            break;
215        }
216        // Sleep until the next tick boundary so pacing does not drift with the
217        // time spent painting.
218        if let Some(remaining) = (start + frame * tick).checked_duration_since(Instant::now()) {
219            std::thread::sleep(remaining);
220        }
221    }
222    reveal.finish()
223}
224
225/// Diff `progress`'s frame against `state` and repaint only the cells that moved.
226fn paint(
227    out: &mut io::Stdout,
228    art: &Art,
229    ranks: &RankMap,
230    style: &Style,
231    state: &mut [CellState],
232    progress: f32,
233    (ox, oy): (u16, u16),
234) -> io::Result<()> {
235    let mut dirty = false;
236    for y in 0..art.height() {
237        for x in 0..art.width() {
238            let idx = art.index(x, y);
239            let target = match ranks.rank_at(x, y) {
240                Some(r) if r <= progress => {
241                    let level = match style.palette {
242                        // A rainbow cell's colour is fixed by position, so it
243                        // settles immediately and never needs a frontier repaint.
244                        Palette::Rainbow => GLOW_LEVELS,
245                        Palette::Glow if style.feather <= 0.0 => GLOW_LEVELS,
246                        Palette::Glow => {
247                            let a = ((progress - r) / style.feather).clamp(0.0, 1.0);
248                            (a * GLOW_LEVELS as f32).round() as u8
249                        }
250                    };
251                    CellState::Lit(level)
252                }
253                _ => CellState::Hidden,
254            };
255
256            if state[idx] == target {
257                continue;
258            }
259            queue!(out, MoveTo(ox + x, oy + y))?;
260            match target {
261                CellState::Hidden => queue!(out, Print(' '))?,
262                CellState::Lit(level) => {
263                    if style.color {
264                        let (r, g, b) = match style.palette {
265                            Palette::Rainbow => rainbow_rgb(x, y, 0.0),
266                            Palette::Glow => {
267                                blend(style.head, style.body, level as f32 / GLOW_LEVELS as f32)
268                            }
269                        };
270                        queue!(out, SetForegroundColor(Color::Rgb { r, g, b }))?;
271                    }
272                    queue!(out, Print(art.glyph(x, y)))?;
273                }
274            }
275            state[idx] = target;
276            dirty = true;
277        }
278    }
279
280    if dirty {
281        queue!(out, ResetColor)?;
282        out.flush()?;
283    }
284    Ok(())
285}
286
287/// Linear interpolation between two RGB colours; `s == 0` yields `a`, `s == 1` yields `b`.
288fn blend(a: (u8, u8, u8), b: (u8, u8, u8), s: f32) -> (u8, u8, u8) {
289    let lerp = |x: u8, y: u8| {
290        (x as f32 + (y as f32 - x as f32) * s)
291            .round()
292            .clamp(0.0, 255.0) as u8
293    };
294    (lerp(a.0, b.0), lerp(a.1, b.1), lerp(a.2, b.2))
295}
296
297/// The colour an ink cell shows at `progress`: `head` at the frontier, easing to
298/// `body` once it has settled `feather` behind. Shared with the loader renderer.
299pub(crate) fn frontier_rgb(style: &Style, progress: f32, rank: f32) -> (u8, u8, u8) {
300    if style.feather <= 0.0 {
301        return style.body;
302    }
303    let a = ((progress - rank) / style.feather).clamp(0.0, 1.0);
304    blend(style.head, style.body, a)
305}
306
307/// The colour of a revealed cell, honouring the style's palette. `t` is elapsed
308/// seconds, which animates the rainbow; pass `0.0` for a still frame.
309pub(crate) fn cell_rgb(
310    style: &Style,
311    progress: f32,
312    rank: f32,
313    x: u16,
314    y: u16,
315    t: f32,
316) -> (u8, u8, u8) {
317    match style.palette {
318        Palette::Glow => frontier_rgb(style, progress, rank),
319        Palette::Rainbow => rainbow_rgb(x, y, t),
320    }
321}
322
323/// A `lolcat` style hue from a cell's position, drifting over time.
324fn rainbow_rgb(x: u16, y: u16, t: f32) -> (u8, u8, u8) {
325    let hue = (x as f32 * 0.05 + y as f32 * 0.12 + t * 0.4).rem_euclid(1.0);
326    hsl_to_rgb(hue, 0.95, 0.62)
327}
328
329/// HSL to RGB, with hue in `0..1`.
330fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
331    let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
332    let hp = h * 6.0;
333    let x = c * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs());
334    let (r, g, b) = match hp as u32 {
335        0 => (c, x, 0.0),
336        1 => (x, c, 0.0),
337        2 => (0.0, c, x),
338        3 => (0.0, x, c),
339        4 => (x, 0.0, c),
340        _ => (c, 0.0, x),
341    };
342    let m = l - c / 2.0;
343    let to = |v: f32| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8;
344    (to(r), to(g), to(b))
345}