Skip to main content

retroglyph_core/symbols/
subcell.rs

1//! Posterizes small blocks of raw pixels to the best-matching Unicode block-element glyph.
2//!
3//! Splits a source image into one small pixel block per terminal cell, then pick whichever glyph
4//! plus foreground/background color pair best reconstructs that block. Three block shapes are
5//! supported, in increasing fidelity (and decreasing terminal compatibility):
6//!
7//! - [`quantize_half_block`](crate::symbols::quantize_half_block): 1x2 pixels -> `' '`/`▀`/`▄`/`█` (Unicode Block Elements, supported
8//!   almost everywhere monospace fonts render at all).
9//! - [`quantize_quadrant`](crate::symbols::quantize_quadrant): 2x2 pixels -> the 16 quadrant block characters (`▘▝▀▖▌▞▛...`).
10//! - [`quantize_sextant`](crate::symbols::quantize_sextant): 2x3 pixels -> the 64 "Symbols for Legacy Computing" sextant
11//!   characters, doubling vertical resolution again over quadrants. Newest and least
12//!   universally supported of the three (a 2022 Unicode addition).
13//!
14//! Callers own the fallback chain: probe terminal/font support (or just take a caller-supplied
15//! capability flag) and call whichever function matches, sampling the source image at that
16//! function's pixel geometry. There's no single "auto-detect and degrade" entry point here,
17//! matching every other terminal-capability decision in retroglyph (e.g. `egc` support):
18//! detection policy lives with the backend, not with this pure geometry/color utility.
19//!
20//! # Algorithm
21//!
22//! For an N-pixel block, every one of the `2^N` ways to split the block into a "foreground set"
23//! and "background set" is scored: average the two sets' colors, then sum each pixel's squared
24//! distance to whichever average it was assigned to. The split with the lowest total error wins,
25//! and its bit pattern selects the glyph directly (the glyph tables below are indexed by that
26//! same pattern, foreground bits set, read row-major). This exhaustive search is cheap here (at
27//! most 64 candidates, 6 pixels each, for [`quantize_sextant`](crate::symbols::quantize_sextant)) and is the same technique
28//! notcurses' blitter chain documents using for its own 3x2 sextant solver.
29//!
30//! Ties (multiple patterns reconstructing a block with equally minimal error) resolve to the
31//! lower-numbered pattern, matching the tie-break convention `retroglyph_core::color`'s own
32//! nearest-color search already uses. Two tie shapes come up often enough to call out: a flat,
33//! single-color block ties across every pattern (all give zero error) and always resolves to
34//! pattern `0`, the cheapest glyph, a plain space colored by `bg`. And any block with exactly
35//! two distinct pixel colors has exactly two zero-error patterns, one the bitwise complement of
36//! the other (swap which color is called `fg` and which is `bg` and the reconstruction is
37//! identical); the lower pattern number wins there too.
38//!
39//! # Example
40//!
41//! ```
42//! use retroglyph_core::symbols::quantize_quadrant;
43//!
44//! // A block that's white in the top-left corner, black everywhere else.
45//! let black = (0, 0, 0);
46//! let white = (255, 255, 255);
47//! let glyph = quantize_quadrant([white, black, black, black]);
48//! assert_eq!(glyph.ch, '▘'); // top-left quadrant
49//! assert_eq!(glyph.fg, retroglyph_core::color::Color::rgb(255, 255, 255));
50//! assert_eq!(glyph.bg, retroglyph_core::color::Color::rgb(0, 0, 0));
51//! ```
52
53use crate::color::Color;
54
55/// A raw 24-bit RGB pixel sample: `(r, g, b)`, one byte per channel.
56pub type Pixel = (u8, u8, u8);
57
58/// The Unicode Block Elements glyphs for a 1-wide x 2-tall pixel block, indexed by a 2-bit
59/// pattern (bit 0 = top pixel set, bit 1 = bottom pixel set).
60pub const HALF_BLOCKS: [char; 4] = [' ', '▀', '▄', '█'];
61
62/// The 16 quadrant block glyphs for a 2x2 pixel block, indexed by a 4-bit pattern in row-major
63/// order (bit 0 = top-left, bit 1 = top-right, bit 2 = bottom-left, bit 3 = bottom-right).
64///
65/// Adapted from [ratatui-core's `symbols::pixel::QUADRANTS`][ratatui].
66///
67/// [ratatui]: https://github.com/ratatui/ratatui/blob/main/ratatui-core/src/symbols/pixel.rs
68pub const QUADRANTS: [char; 16] = [
69    ' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█',
70];
71
72/// The 64 sextant glyphs for a 2x3 pixel block.
73///
74/// Indexed by a 6-bit pattern in row-major order (bit 0 = top-left, bit 1 = top-right, bit 2 =
75/// mid-left, bit 3 = mid-right, bit 4 = bottom-left, bit 5 = bottom-right). Mostly from Unicode's
76/// "Symbols for Legacy Computing" block; adapted from [ratatui-core's
77/// `symbols::pixel::SEXTANTS`][ratatui].
78///
79/// [ratatui]: https://github.com/ratatui/ratatui/blob/main/ratatui-core/src/symbols/pixel.rs
80#[rustfmt::skip]
81pub const SEXTANTS: [char; 64] = [
82    ' ', '🬀', '🬁', '🬂', '🬃', '🬄', '🬅', '🬆', '🬇', '🬈', '🬉', '🬊', '🬋', '🬌', '🬍', '🬎',
83    '🬏', '🬐', '🬑', '🬒', '🬓', '▌', '🬔', '🬕', '🬖', '🬗', '🬘', '🬙', '🬚', '🬛', '🬜', '🬝',
84    '🬞', '🬟', '🬠', '🬡', '🬢', '🬣', '🬤', '🬥', '🬦', '🬧', '▐', '🬨', '🬩', '🬪', '🬫', '🬬',
85    '🬭', '🬮', '🬯', '🬰', '🬱', '🬲', '🬳', '🬴', '🬵', '🬶', '🬷', '🬸', '🬹', '🬺', '🬻', '█',
86];
87
88/// A posterized pixel block: the best-matching glyph plus its foreground and background colors.
89///
90/// The background color is only meaningful for glyphs that don't cover the full cell (anything
91/// but `' '` and `'█'`); the foreground color is only meaningful for glyphs other than `' '`.
92/// Both are still populated for those edge cases (as the block's overall average color) so a
93/// caller never has to special-case `Glyph` before styling a cell with it.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub struct Glyph {
96    /// The selected block-element/quadrant/sextant character.
97    pub ch: char,
98    /// The color assigned to the "on" pixels (those set in the glyph's bit pattern).
99    pub fg: Color,
100    /// The color assigned to the "off" pixels (those clear in the glyph's bit pattern).
101    pub bg: Color,
102}
103
104/// Averages the `pixels` selected by `mask` (bit `i` set means `pixels[i]` is included), rounding
105/// each channel to the nearest integer. Returns `None` if `mask` selects no pixels.
106fn average(pixels: &[Pixel], mask: usize) -> Option<Pixel> {
107    let (mut r, mut g, mut b, mut n) = (0u32, 0u32, 0u32, 0u32);
108    for (i, &(pr, pg, pb)) in pixels.iter().enumerate() {
109        if mask & (1 << i) != 0 {
110            r += u32::from(pr);
111            g += u32::from(pg);
112            b += u32::from(pb);
113            n += 1;
114        }
115    }
116    if n == 0 {
117        return None;
118    }
119    let round = |sum: u32| u8::try_from((sum + n / 2) / n).unwrap_or(u8::MAX);
120    Some((round(r), round(g), round(b)))
121}
122
123/// Posterizes `pixels` to the glyph (from `table`, indexed by row-major bit pattern) and two
124/// representative colors that minimize total squared color error, by exhaustive search over
125/// every `2^N` way to split the block into a foreground and background set.
126///
127/// `table.len()` must be `2^pixels.len()`; every caller in this module upholds that by
128/// construction, so this stays a plain slice rather than a const-generic-sized array (which
129/// would need unstable `generic_const_exprs` to relate `N` to `table`'s length at the type
130/// level).
131fn posterize(pixels: &[Pixel], table: &[char]) -> Glyph {
132    let full_mask = table.len() - 1;
133    let overall = average(pixels, full_mask).unwrap_or((0, 0, 0));
134
135    let mut best_pattern = 0usize;
136    let mut best_error = u32::MAX;
137    for mask in 0..table.len() {
138        let fg = average(pixels, mask).unwrap_or(overall);
139        let bg = average(pixels, !mask & full_mask).unwrap_or(overall);
140        let mut error = 0u32;
141        for (i, &pixel) in pixels.iter().enumerate() {
142            let assigned = if mask & (1 << i) != 0 { fg } else { bg };
143            error += gem::rgb::distance_sq(pixel, assigned);
144        }
145        if error < best_error {
146            best_error = error;
147            best_pattern = mask;
148        }
149    }
150
151    let fg = average(pixels, best_pattern).unwrap_or(overall);
152    let bg = average(pixels, !best_pattern & full_mask).unwrap_or(overall);
153    Glyph {
154        ch: table[best_pattern],
155        fg: Color::rgb(fg.0, fg.1, fg.2),
156        bg: Color::rgb(bg.0, bg.1, bg.2),
157    }
158}
159
160/// Posterizes a 1-wide x 2-tall pixel block (`[top, bottom]`) to `' '`/`▀`/`▄`/`█` plus two
161/// representative colors.
162///
163/// This is the lowest-fidelity, most compatible option: plain Unicode Block Elements, supported
164/// by essentially every monospace terminal font.
165///
166/// See the `16_subcell_image` example for `quantize_half_block` in action:
167/// <https://main.retroglyph.dev/examples/16_subcell_image/terminal/>.
168///
169/// # Examples
170///
171/// ```
172/// use retroglyph_core::symbols::quantize_half_block;
173///
174/// let glyph = quantize_half_block([(255, 255, 255), (0, 0, 0)]);
175/// assert_eq!(glyph.ch, '▀'); // top half set, bottom clear
176/// ```
177///
178/// Never panics: `pixels` is a fixed-size 2-element array, so there is no length to validate
179/// and no index into it that can be out of bounds.
180#[must_use]
181pub fn quantize_half_block(pixels: [impl Into<Pixel>; 2]) -> Glyph {
182    let pixels = pixels.map(Into::into);
183    posterize(&pixels, &HALF_BLOCKS)
184}
185
186/// Posterizes a 2x2 pixel block (`[top_left, top_right, bottom_left, bottom_right]`) to one of
187/// the 16 quadrant block glyphs plus two representative colors.
188///
189/// Doubles both horizontal and vertical resolution over [`quantize_half_block`](crate::symbols::quantize_half_block).
190///
191/// On the bundled pixel backends (`retroglyph-software`, `retroglyph-gl`), rendering these
192/// glyphs correctly requires a font that actually declares coverage for the quadrant block
193/// characters: CP437 has no mapping for them. A font built with `retroglyph_window`'s
194/// `BitmapFont::new` (CP437-only) renders every quadrant glyph as a solid block. Supply quadrant
195/// coverage by passing either a primary font or a `BitmapFont::with_charset` fallback in a
196/// `FontChain` to those backends' `font()` builder method; the glyph then takes the cell's
197/// foreground color, which a tileset sprite (the other way to draw a non-CP437 shape) does not.
198///
199/// See the `16_subcell_image` example for `quantize_quadrant` in action:
200/// <https://main.retroglyph.dev/examples/16_subcell_image/terminal/>.
201///
202/// # Examples
203///
204/// ```
205/// use retroglyph_core::symbols::quantize_quadrant;
206///
207/// let black = (0, 0, 0);
208/// let white = (255, 255, 255);
209/// let glyph = quantize_quadrant([black, white, black, black]);
210/// assert_eq!(glyph.ch, '▝'); // top-right quadrant
211/// ```
212///
213/// See [`quantize_half_block`](crate::symbols::quantize_half_block) for why this never panics.
214#[must_use]
215pub fn quantize_quadrant(pixels: [impl Into<Pixel>; 4]) -> Glyph {
216    let pixels = pixels.map(Into::into);
217    posterize(&pixels, &QUADRANTS)
218}
219
220/// Posterizes a 2-wide x 3-tall pixel block (`[top_left, top_right, mid_left, mid_right,
221/// bottom_left, bottom_right]`) to one of the 64 sextant glyphs plus two representative colors.
222///
223/// The highest-fidelity option (doubles vertical resolution again over [`quantize_quadrant`](crate::symbols::quantize_quadrant)),
224/// and the newest/least universally supported: sextant glyphs come from a 2022 Unicode addition
225/// and need a font with "Symbols for Legacy Computing" coverage to render as blocks rather than
226/// tofu/replacement characters.
227///
228/// Font coverage works the same way as [`quantize_quadrant`](crate::symbols::quantize_quadrant)'s: CP437 has no mapping for sextant
229/// glyphs either, so see that function's docs for the `FontChain` setup needed to render them.
230///
231/// See the `16_subcell_image` example for `quantize_sextant` in action:
232/// <https://main.retroglyph.dev/examples/16_subcell_image/terminal/>.
233///
234/// # Examples
235///
236/// ```
237/// use retroglyph_core::symbols::quantize_sextant;
238///
239/// let black = (0, 0, 0);
240/// let white = (255, 255, 255);
241/// let glyph = quantize_sextant([white, black, black, black, black, black]);
242/// assert_eq!(glyph.ch, '🬀'); // top-left sextant only
243/// ```
244///
245/// See [`quantize_half_block`](crate::symbols::quantize_half_block) for why this never panics.
246#[must_use]
247pub fn quantize_sextant(pixels: [impl Into<Pixel>; 6]) -> Glyph {
248    let pixels = pixels.map(Into::into);
249    posterize(&pixels, &SEXTANTS)
250}
251
252#[cfg(test)]
253mod tests {
254    use super::{Color, average, quantize_half_block, quantize_quadrant, quantize_sextant};
255
256    const BLACK: (u8, u8, u8) = (0, 0, 0);
257    const WHITE: (u8, u8, u8) = (255, 255, 255);
258    const RED: (u8, u8, u8) = (200, 0, 0);
259
260    #[test]
261    fn distance_sq_matches_manual_euclidean() {
262        assert_eq!(gem::rgb::distance_sq(BLACK, WHITE), 255 * 255 * 3);
263        assert_eq!(gem::rgb::distance_sq(BLACK, BLACK), 0);
264    }
265
266    #[test]
267    fn average_rounds_to_nearest_and_handles_empty_mask() {
268        assert_eq!(average(&[BLACK, WHITE], 0b11), Some((128, 128, 128)));
269        assert_eq!(average(&[BLACK, WHITE], 0b01), Some(BLACK));
270        assert_eq!(average(&[BLACK, WHITE], 0b00), None);
271    }
272
273    #[test]
274    fn half_block_uniform_color_picks_space_with_that_color() {
275        // A flat block ties across every pattern (all zero error); pattern 0 (the cheapest
276        // glyph, a space) always wins that tie. `fg` still comes back populated so a caller
277        // never has to special-case a uniform block before styling with it.
278        let glyph = quantize_half_block([WHITE, WHITE]);
279        assert_eq!(glyph.ch, ' ');
280        assert_eq!(glyph.bg, Color::rgb(255, 255, 255));
281        assert_eq!(glyph.fg, glyph.bg);
282    }
283
284    #[test]
285    fn half_block_top_bottom_split() {
286        let glyph = quantize_half_block([WHITE, BLACK]);
287        assert_eq!(glyph.ch, '▀');
288        assert_eq!(glyph.fg, Color::rgb(255, 255, 255));
289        assert_eq!(glyph.bg, Color::rgb(0, 0, 0));
290    }
291
292    #[test]
293    fn quadrant_picks_bottom_left_glyph() {
294        // Bit 2 (bottom-left) is the only set pixel.
295        let glyph = quantize_quadrant([BLACK, BLACK, WHITE, BLACK]);
296        assert_eq!(glyph.ch, '▖');
297    }
298
299    #[test]
300    fn quadrant_uniform_color_picks_space_with_that_color() {
301        let glyph = quantize_quadrant([RED, RED, RED, RED]);
302        assert_eq!(glyph.ch, ' ');
303        assert_eq!(glyph.bg, Color::rgb(200, 0, 0));
304        assert_eq!(glyph.fg, glyph.bg);
305    }
306
307    #[test]
308    fn sextant_prefers_lower_pattern_on_exact_ties() {
309        // A perfect flat block ties every pattern at zero error; pattern 0 (space) always wins.
310        let grey = (128, 128, 128);
311        let glyph = quantize_sextant([grey, grey, grey, grey, grey, grey]);
312        assert_eq!(glyph.ch, ' ');
313    }
314
315    #[test]
316    fn sextant_single_pixel_set() {
317        let glyph = quantize_sextant([BLACK, BLACK, BLACK, BLACK, WHITE, BLACK]);
318        assert_eq!(glyph.ch, '🬏'); // bottom-left sextant only (bit 4)
319        assert_eq!(glyph.fg, Color::rgb(255, 255, 255));
320        assert_eq!(glyph.bg, Color::rgb(0, 0, 0));
321    }
322
323    #[test]
324    fn quantize_fns_accept_rgb888_directly() {
325        // The whole point of `impl Into<Pixel>`: a caller holding `gem::rgb::Rgb888` (as
326        // opposed to a bare `(u8, u8, u8)`) passes it with no `.to_rgb()` call, via gem 0.2.2's
327        // `From<Rgb888> for (u8, u8, u8)`.
328        use gem::rgb::Rgb888;
329
330        let white = Rgb888::from_rgb(255, 255, 255);
331        let black = Rgb888::from_rgb(0, 0, 0);
332
333        assert_eq!(quantize_half_block([white, black]).ch, '▀');
334        assert_eq!(quantize_quadrant([black, black, white, black]).ch, '▖');
335        assert_eq!(
336            quantize_sextant([black, black, black, black, white, black]).ch,
337            '🬏'
338        );
339    }
340}