Skip to main content

katui_present/
blocks.rs

1//! The blocks fallback: pixels drawn as half-block and quarter-block
2//! glyphs over SGR truecolor, the one pixel path every color terminal
3//! understands.
4
5use crate::Sink;
6use std::fmt::Write as _;
7
8/// An opaque color, the pixel the classifiers work with.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct Rgb {
11    pub r: u8,
12    pub g: u8,
13    pub b: u8,
14}
15
16impl Rgb {
17    fn distance_squared(self, other: Rgb) -> u32 {
18        let dr = u32::from(self.r.abs_diff(other.r));
19        let dg = u32::from(self.g.abs_diff(other.g));
20        let db = u32::from(self.b.abs_diff(other.b));
21        dr * dr + dg * dg + db * db
22    }
23
24    fn luminance(self) -> u32 {
25        2_126 * u32::from(self.r) + 7_152 * u32::from(self.g) + 722 * u32::from(self.b)
26    }
27}
28
29/// One classified cell: the glyph plus the two colors it draws with.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct Block {
32    /// The glyph, one of the half or quarter block characters.
33    pub glyph: char,
34    pub fg: Rgb,
35    pub bg: Rgb,
36}
37
38/// The sixteen quarter-block glyphs, indexed by a four-bit mask:
39/// bit 1 top-left, bit 2 top-right, bit 4 bottom-left, bit 8 bottom-right.
40const QUARTERS: [char; 16] = [
41    ' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█',
42];
43
44/// Classify a 2x2 pixel block into one quarter-block cell.
45///
46/// The most distant pair of the four pixels becomes the cell's two colors,
47/// the more luminant one as the foreground; every quadrant joins whichever
48/// of the two it sits nearer to. Four equal pixels collapse to a full
49/// block.
50#[must_use]
51pub fn quarter(pixels: [[Rgb; 2]; 2]) -> Block {
52    let [[top_left, top_right], [bottom_left, bottom_right]] = pixels;
53    let corners = [
54        (top_left, 1u8),
55        (top_right, 2),
56        (bottom_left, 4),
57        (bottom_right, 8),
58    ];
59    let mut best: Option<(Rgb, Rgb, u32)> = None;
60    for (first, (pixel, _)) in corners.iter().enumerate() {
61        for (other, _) in corners.iter().skip(first + 1) {
62            let distance = pixel.distance_squared(*other);
63            let improved = best.is_none_or(|(_, _, best_distance)| distance > best_distance);
64            if improved {
65                best = Some((*pixel, *other, distance));
66            }
67        }
68    }
69    let (left, right) = match best {
70        Some((first, second, _)) => (first, second),
71        None => (top_left, top_right),
72    };
73    let (fg, bg) = if left.luminance() >= right.luminance() {
74        (left, right)
75    } else {
76        (right, left)
77    };
78    let mut mask = 0u8;
79    for (pixel, bit) in corners {
80        if pixel.distance_squared(fg) <= pixel.distance_squared(bg) {
81            mask |= bit;
82        }
83    }
84    let glyph = QUARTERS.get(usize::from(mask)).copied().unwrap_or(' ');
85    Block { glyph, fg, bg }
86}
87
88/// Classify two vertically stacked pixels into one half-block cell.
89#[must_use]
90pub fn half(top: Rgb, bottom: Rgb) -> Block {
91    if top == bottom {
92        return Block {
93            glyph: ' ',
94            fg: top,
95            bg: top,
96        };
97    }
98    Block {
99        glyph: '▀',
100        fg: top,
101        bg: bottom,
102    }
103}
104
105/// Render one terminal line of a pixel image into `out`: every cell
106/// classifies the 2x2 pixel block under it, a trailing odd column becomes
107/// a half block, in SGR truecolor. Rows of different lengths stop at the
108/// shorter one.
109pub fn render_line<W: Sink>(out: &mut W, top: &[Rgb], bottom: &[Rgb]) -> std::io::Result<()> {
110    let mut text = String::new();
111    for (top_pair, bottom_pair) in top.chunks(2).zip(bottom.chunks(2)) {
112        let block = match (top_pair, bottom_pair) {
113            ([tl, tr], [bl, br]) => quarter([[*tl, *tr], [*bl, *br]]),
114            (odd_top, odd_bottom) => match (odd_top.first(), odd_bottom.first()) {
115                (Some(top), Some(bottom)) => half(*top, *bottom),
116                _ => break,
117            },
118        };
119        let _ = write!(
120            text,
121            "\x1b[38;2;{r};{g};{b}m\x1b[48;2;{r2};{g2};{b2}m{glyph}\x1b[0m",
122            r = block.fg.r,
123            g = block.fg.g,
124            b = block.fg.b,
125            r2 = block.bg.r,
126            g2 = block.bg.g,
127            b2 = block.bg.b,
128            glyph = block.glyph,
129        );
130    }
131    out.write_all(text.as_bytes())
132}
133
134#[cfg(test)]
135mod tests {
136    use super::{Block, Rgb, half, quarter};
137
138    const WHITE: Rgb = Rgb {
139        r: 255,
140        g: 255,
141        b: 255,
142    };
143    const BLACK: Rgb = Rgb { r: 0, g: 0, b: 0 };
144
145    #[test]
146    fn four_equal_pixels_collapse_to_a_full_block() {
147        let red = Rgb {
148            r: 200,
149            g: 30,
150            b: 30,
151        };
152        assert_eq!(
153            quarter([[red; 2]; 2]),
154            Block {
155                glyph: '█',
156                fg: red,
157                bg: red
158            }
159        );
160    }
161
162    #[test]
163    fn a_split_row_makes_the_upper_half() {
164        let cell = quarter([[WHITE, WHITE], [BLACK, BLACK]]);
165        assert_eq!(
166            cell,
167            Block {
168                glyph: '▀',
169                fg: WHITE,
170                bg: BLACK
171            }
172        );
173    }
174
175    #[test]
176    fn the_darker_pair_member_takes_the_background() {
177        let cell = quarter([[BLACK, BLACK], [WHITE, WHITE]]);
178        assert_eq!(cell.glyph, '▄');
179        assert_eq!(cell.fg, WHITE);
180        assert_eq!(cell.bg, BLACK);
181    }
182
183    #[test]
184    fn a_checkerboard_picks_the_diagonal_glyph() {
185        let cell = quarter([[WHITE, BLACK], [BLACK, WHITE]]);
186        assert_eq!(cell.glyph, '▚');
187        assert_eq!(cell.fg, WHITE);
188        assert_eq!(cell.bg, BLACK);
189    }
190
191    #[test]
192    fn the_other_diagonal_picks_its_own_glyph() {
193        let cell = quarter([[BLACK, WHITE], [WHITE, BLACK]]);
194        assert_eq!(cell.glyph, '▞');
195        assert_eq!(cell.fg, WHITE);
196        assert_eq!(cell.bg, BLACK);
197    }
198
199    #[test]
200    fn a_left_column_makes_the_left_half() {
201        let cell = quarter([[WHITE, BLACK], [WHITE, BLACK]]);
202        assert_eq!(cell.glyph, '▌');
203    }
204
205    #[test]
206    fn each_quadrant_joins_its_nearer_color() {
207        let near_black = Rgb { r: 5, g: 5, b: 5 };
208        let cell = quarter([[WHITE, near_black], [near_black, WHITE]]);
209        assert_eq!(cell.glyph, '▚');
210        assert_eq!(cell.fg, WHITE);
211        assert_eq!(cell.bg, near_black);
212    }
213
214    #[test]
215    fn half_blocks_split_or_fill() {
216        assert_eq!(
217            half(WHITE, BLACK),
218            Block {
219                glyph: '▀',
220                fg: WHITE,
221                bg: BLACK
222            }
223        );
224        assert_eq!(half(BLACK, BLACK).glyph, ' ');
225    }
226
227    #[test]
228    fn render_line_writes_golden_sgr_bytes() {
229        let mut out = Vec::new();
230        super::render_line(&mut out, &[WHITE, BLACK], &[BLACK, WHITE])
231            .expect("writing to a Vec cannot fail");
232        assert_eq!(
233            String::from_utf8_lossy(&out),
234            "\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m▚\x1b[0m"
235        );
236    }
237
238    #[test]
239    fn a_trailing_odd_column_renders_a_half_block() {
240        let mut out = Vec::new();
241        super::render_line(&mut out, &[WHITE, BLACK, WHITE], &[BLACK, WHITE, BLACK])
242            .expect("writing to a Vec cannot fail");
243        let text = String::from_utf8_lossy(&out);
244        assert!(text.contains('▀'), "the odd column becomes a half block");
245        assert_eq!(text.chars().filter(|c| *c == '▀').count(), 1);
246    }
247
248    #[test]
249    fn empty_rows_render_nothing() {
250        let mut out = Vec::new();
251        super::render_line(&mut out, &[], &[]).expect("writing to a Vec cannot fail");
252        assert!(out.is_empty());
253    }
254}