Skip to main content

inkling/
frame.rs

1//! Pure, dependency-free description of a single reveal frame.
2//!
3//! This module owns the answer to "what does cell `(x, y)` look like at this
4//! progress". Every renderer in the crate, the plain-text one below, the diffing
5//! terminal one in [`crate::render`], and the live loader, walks frames through
6//! [`row`] rather than re-deriving visibility for itself. Colour is layered on
7//! top by the terminal renderers; the shape of the frame is decided here, once.
8
9use crate::{art::Art, rank::RankMap, width::glyph_cols};
10
11/// How one cell of a frame appears.
12///
13/// `cols` is the display width the cell occupies either way, so a hidden wide
14/// glyph reserves the same two columns it will take once revealed and the row
15/// never shifts sideways as the reveal crosses it.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum Paint {
18    /// Background, or ink not yet revealed.
19    Blank { cols: u16 },
20    /// Revealed ink.
21    Ink { glyph: char, cols: u16 },
22}
23
24impl Paint {
25    /// Display columns this cell occupies.
26    #[inline]
27    pub fn cols(self) -> u16 {
28        match self {
29            Paint::Blank { cols } | Paint::Ink { cols, .. } => cols,
30        }
31    }
32
33    /// The revealed glyph, if any.
34    #[inline]
35    pub fn glyph(self) -> Option<char> {
36        match self {
37            Paint::Ink { glyph, .. } => Some(glyph),
38            Paint::Blank { .. } => None,
39        }
40    }
41}
42
43/// The appearance of one cell at `progress`.
44#[inline]
45pub fn cell(art: &Art, ranks: &RankMap, progress: f32, x: u16, y: u16) -> Paint {
46    let glyph = art.glyph(x, y);
47    let cols = glyph_cols(glyph).max(1);
48    if ranks.visible_at(x, y, progress) {
49        Paint::Ink { glyph, cols }
50    } else {
51        Paint::Blank { cols }
52    }
53}
54
55/// Walk row `y` of the frame at `progress`, yielding each cell's grid column, the
56/// display column it starts at, and how it paints.
57///
58/// This is the walk every renderer shares.
59pub fn row<'a>(
60    art: &'a Art,
61    ranks: &'a RankMap,
62    progress: f32,
63    y: u16,
64) -> impl Iterator<Item = (u16, u16, Paint)> + 'a {
65    let mut col = 0u16;
66    (0..art.width()).map(move |x| {
67        let paint = cell(art, ranks, progress, x, y);
68        let at = col;
69        col = col.saturating_add(paint.cols());
70        (x, at, paint)
71    })
72}
73
74/// Display columns the widest row of `art` occupies.
75pub fn art_cols(art: &Art) -> u16 {
76    (0..art.height())
77        .map(|y| {
78            (0..art.width())
79                .map(|x| glyph_cols(art.glyph(x, y)).max(1))
80                .fold(0u16, u16::saturating_add)
81        })
82        .max()
83        .unwrap_or(0)
84}
85
86/// Render the frame at `progress` as plain text: ink whose rank is `<= progress`
87/// is shown, everything else is padded with spaces to the same display width.
88/// Trailing spaces on each line are trimmed. The result always has exactly
89/// `art.height()` lines.
90pub fn to_string(art: &Art, ranks: &RankMap, progress: f32) -> String {
91    let mut out = String::with_capacity(art.cell_count() + art.height() as usize);
92    let mut line = String::with_capacity(art.width() as usize);
93    for y in 0..art.height() {
94        line.clear();
95        for (_, _, paint) in row(art, ranks, progress, y) {
96            match paint {
97                Paint::Ink { glyph, .. } => line.push(glyph),
98                // Reserve the glyph's full width so the row does not shift
99                // sideways as the reveal crosses a wide glyph.
100                Paint::Blank { cols } => (0..cols).for_each(|_| line.push(' ')),
101            }
102        }
103        out.push_str(line.trim_end());
104        out.push('\n');
105    }
106    out
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::ordering::{Geodesic, Ordering};
113
114    #[test]
115    fn empty_at_zero_full_at_one() {
116        let art = Art::parse("/\\__/\\\n\\____/");
117        let ranks = Geodesic::default().rank(&art);
118
119        // Rank 0 exists, so progress 0.0 reveals at least the start cell but not
120        // the whole picture; progress 1.0 reveals everything.
121        let none = to_string(&art, &ranks, -0.001);
122        let all = to_string(&art, &ranks, 1.0);
123
124        assert!(none.trim().chars().all(|c| c.is_whitespace()));
125        assert_eq!(all.replace([' ', '\n'], "").len(), art.ink_count());
126    }
127
128    #[test]
129    fn reveal_is_monotonic() {
130        let art = Art::parse("####\n#  #\n####");
131        let ranks = Geodesic::default().rank(&art);
132        let mut last = 0;
133        for i in 0..=10 {
134            let shown = to_string(&art, &ranks, i as f32 / 10.0)
135                .chars()
136                .filter(|c| !c.is_whitespace())
137                .count();
138            assert!(shown >= last, "reveal went backwards at step {i}");
139            last = shown;
140        }
141        assert_eq!(last, art.ink_count());
142    }
143
144    #[test]
145    fn always_has_one_line_per_row() {
146        let art = Art::parse("#\n#\n#");
147        let ranks = Geodesic::default().rank(&art);
148        assert_eq!(to_string(&art, &ranks, 0.5).lines().count(), 3);
149    }
150
151    /// A hidden cell reserves the columns its glyph will need, so every row keeps
152    /// a constant display width for the whole reveal and nothing shifts sideways.
153    #[cfg(feature = "unicode")]
154    #[test]
155    fn row_width_is_constant_across_the_reveal() {
156        use crate::width::str_cols;
157        let art = Art::parse("世a界b");
158        let ranks = Geodesic::default().rank(&art);
159        let widths: Vec<u16> = (0..=10)
160            .map(|i| {
161                let text = to_string(&art, &ranks, i as f32 / 10.0);
162                // Measure before the trailing trim, which is cosmetic.
163                let padded: String = row(&art, &ranks, i as f32 / 10.0, 0)
164                    .map(|(_, _, p)| match p {
165                        Paint::Ink { glyph, .. } => glyph.to_string(),
166                        Paint::Blank { cols } => " ".repeat(cols as usize),
167                    })
168                    .collect();
169                assert!(text.lines().count() == 1);
170                str_cols(&padded)
171            })
172            .collect();
173        assert!(
174            widths.windows(2).all(|w| w[0] == w[1]),
175            "row width drifted during the reveal: {widths:?}"
176        );
177        assert_eq!(widths[0], 6); // two wide glyphs plus two narrow
178    }
179
180    #[test]
181    fn art_cols_counts_display_width() {
182        let art = Art::parse("ab\nabc");
183        assert_eq!(art_cols(&art), 3);
184    }
185}