Skip to main content

ascii_dag/render/engine/
color.rs

1//! Packed cell colors (temp/06 §4, ruling Q8).
2//!
3//! [`CellColor`] stores color **semantically** in one tagged `u32`
4//! (default / ANSI-256 index / 24-bit RGB) — storage never knows the
5//! output mode. Conversion happens once per emitted cell, per the active
6//! [`ColorMode`]: RGB quantizes to the xterm 256 cube, ANSI expands to
7//! RGB for truecolor sinks.
8//!
9//! Planes built from this type are gated (no plane when colors are off)
10//! and band-sized, so color memory is bounded by `width × band_rows`
11//! regardless of graph size.
12
13/// Output color encoding mode.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15#[non_exhaustive]
16pub enum ColorMode {
17    /// No color output (no color plane is allocated at all).
18    None,
19    /// ANSI 256-color escapes (default; today's palette behavior).
20    #[default]
21    Ansi256,
22    /// 24-bit truecolor escapes.
23    TrueColor,
24}
25
26const TAG_SHIFT: u32 = 30;
27const TAG_ANSI: u32 = 1;
28const TAG_RGB: u32 = 2;
29
30/// One packed color value. All-zero = terminal default (no escape).
31#[derive(Clone, Copy, PartialEq, Eq, Default)]
32pub struct CellColor(u32);
33
34impl CellColor {
35    /// Terminal default — no escape emitted.
36    pub const DEFAULT: CellColor = CellColor(0);
37
38    /// An ANSI-256 palette color.
39    pub const fn ansi256(index: u8) -> CellColor {
40        CellColor((TAG_ANSI << TAG_SHIFT) | index as u32)
41    }
42
43    /// A 24-bit RGB color.
44    pub const fn rgb(r: u8, g: u8, b: u8) -> CellColor {
45        CellColor((TAG_RGB << TAG_SHIFT) | ((r as u32) << 16) | ((g as u32) << 8) | b as u32)
46    }
47
48    /// Whether this is a real color (an escape will be emitted).
49    #[inline]
50    pub fn is_set(self) -> bool {
51        self.0 != 0
52    }
53
54    /// The packed value — for storage in ordered containers.
55    #[inline]
56    pub(crate) fn raw(self) -> u32 {
57        self.0
58    }
59
60    /// Rebuild from a value produced by [`CellColor::raw`].
61    #[inline]
62    pub(crate) fn from_raw(raw: u32) -> CellColor {
63        CellColor(raw)
64    }
65
66    #[inline]
67    pub(crate) fn is_ansi(self) -> bool {
68        self.0 >> TAG_SHIFT == TAG_ANSI
69    }
70
71    #[inline]
72    pub(crate) fn is_rgb(self) -> bool {
73        self.0 >> TAG_SHIFT == TAG_RGB
74    }
75
76    /// The ANSI index of an ANSI-tagged color (undefined otherwise).
77    #[inline]
78    pub(crate) fn ansi_index(self) -> u8 {
79        (self.0 & 0xFF) as u8
80    }
81
82    /// The RGB components of an RGB-tagged color (undefined otherwise).
83    #[inline]
84    pub(crate) fn rgb_parts(self) -> (u8, u8, u8) {
85        (
86            ((self.0 >> 16) & 0xFF) as u8,
87            ((self.0 >> 8) & 0xFF) as u8,
88            (self.0 & 0xFF) as u8,
89        )
90    }
91
92    /// This color as an ANSI-256 index, quantizing RGB if needed.
93    /// Returns `None` for [`CellColor::DEFAULT`] (the consumer's
94    /// "no color set" — use its own default ink).
95    pub fn as_ansi256(self) -> Option<u8> {
96        if self.is_ansi() {
97            Some(self.ansi_index())
98        } else if self.is_rgb() {
99            let (r, g, b) = self.rgb_parts();
100            Some(rgb_to_ansi256(r, g, b))
101        } else {
102            None
103        }
104    }
105
106    /// This color as RGB, expanding an ANSI index if needed.
107    /// Returns `None` for [`CellColor::DEFAULT`] (the consumer's
108    /// "no color set" — use its own default ink). This is what an
109    /// SVG/TUI consumer feeds its own color model.
110    pub fn as_rgb(self) -> Option<(u8, u8, u8)> {
111        if self.is_rgb() {
112            Some(self.rgb_parts())
113        } else if self.is_ansi() {
114            Some(ansi256_to_rgb(self.ansi_index()))
115        } else {
116            None
117        }
118    }
119}
120
121impl core::fmt::Debug for CellColor {
122    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
123        if !self.is_set() {
124            write!(f, "CellColor::DEFAULT")
125        } else if self.is_ansi() {
126            write!(f, "CellColor::ansi256({})", self.ansi_index())
127        } else {
128            let (r, g, b) = self.rgb_parts();
129            write!(f, "CellColor::rgb({r}, {g}, {b})")
130        }
131    }
132}
133
134/// Quantize 24-bit RGB to the nearest xterm-256 index (standard cube +
135/// grayscale-ramp mapping).
136pub(crate) fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
137    // Gray: pick the closer of the 24-step gray ramp (232–255) and the
138    // cube's gray diagonal (0, 95, 135, 175, 215, 255) — the ramp is
139    // denser but the cube holds the exact mid-gray levels.
140    if r == g && g == b {
141        let v = r as i32;
142        let ramp_i = ((v - 8 + 5) / 10).clamp(0, 23);
143        let ramp_v = 8 + 10 * ramp_i;
144        let cube_q = if v < 48 {
145            0
146        } else if v < 115 {
147            1
148        } else {
149            ((v - 35) / 40).min(5)
150        };
151        let cube_v = if cube_q == 0 { 0 } else { 55 + 40 * cube_q };
152        return if (v - cube_v).abs() < (v - ramp_v).abs() {
153            (16 + 43 * cube_q) as u8 // gray diagonal: 16 + 36q + 6q + q
154        } else {
155            (232 + ramp_i) as u8
156        };
157    }
158    let q = |v: u8| -> u16 {
159        // Standard xterm cube breakpoints: 0, 95, 135, 175, 215, 255.
160        if v < 48 {
161            0
162        } else if v < 115 {
163            1
164        } else {
165            ((v as u16) - 35) / 40
166        }
167    };
168    (16 + 36 * q(r) + 6 * q(g) + q(b)) as u8
169}
170
171/// Expand an xterm-256 index to 24-bit RGB.
172pub(crate) fn ansi256_to_rgb(index: u8) -> (u8, u8, u8) {
173    match index {
174        // 16 system colors (standard xterm values).
175        0 => (0, 0, 0),
176        1 => (205, 0, 0),
177        2 => (0, 205, 0),
178        3 => (205, 205, 0),
179        4 => (0, 0, 238),
180        5 => (205, 0, 205),
181        6 => (0, 205, 205),
182        7 => (229, 229, 229),
183        8 => (127, 127, 127),
184        9 => (255, 0, 0),
185        10 => (0, 255, 0),
186        11 => (255, 255, 0),
187        12 => (92, 92, 255),
188        13 => (255, 0, 255),
189        14 => (0, 255, 255),
190        15 => (255, 255, 255),
191        16..=231 => {
192            // 6×6×6 color cube.
193            let i = index as u16 - 16;
194            let level = |c: u16| -> u8 { if c == 0 { 0 } else { (55 + 40 * c) as u8 } };
195            (level(i / 36), level((i / 6) % 6), level(i % 6))
196        }
197        232..=255 => {
198            // Grayscale ramp: 8, 18, …, 238.
199            let v = (8 + 10 * (index as u16 - 232)) as u8;
200            (v, v, v)
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn packing_roundtrips() {
211        assert!(!CellColor::DEFAULT.is_set());
212        let a = CellColor::ansi256(196);
213        assert!(a.is_set() && a.is_ansi());
214        assert_eq!(a.ansi_index(), 196);
215        let c = CellColor::rgb(12, 34, 56);
216        assert!(c.is_set() && c.is_rgb());
217        assert_eq!(c.rgb_parts(), (12, 34, 56));
218    }
219
220    #[test]
221    fn cube_expansion_matches_xterm() {
222        assert_eq!(ansi256_to_rgb(196), (255, 0, 0)); // cube pure red
223        assert_eq!(ansi256_to_rgb(16), (0, 0, 0)); // cube black
224        assert_eq!(ansi256_to_rgb(231), (255, 255, 255)); // cube white
225        assert_eq!(ansi256_to_rgb(244), (128, 128, 128)); // gray ramp middle
226        assert_eq!(ansi256_to_rgb(51), (0, 255, 255)); // cube cyan
227    }
228
229    #[test]
230    fn quantization_hits_cube_corners() {
231        assert_eq!(rgb_to_ansi256(255, 0, 0), 196);
232        assert_eq!(rgb_to_ansi256(0, 255, 255), 51);
233        assert_eq!(rgb_to_ansi256(0, 0, 0), 16);
234        assert_eq!(rgb_to_ansi256(255, 255, 255), 231);
235        assert_eq!(rgb_to_ansi256(128, 128, 128), 244);
236    }
237
238    #[test]
239    fn quantize_expand_is_stable() {
240        // Expanding an index and re-quantizing returns the same index
241        // for the cube and gray ramp (16..=255).
242        for index in 16u16..=255 {
243            let (r, g, b) = ansi256_to_rgb(index as u8);
244            assert_eq!(
245                rgb_to_ansi256(r, g, b),
246                index as u8,
247                "index {index} not stable"
248            );
249        }
250    }
251
252    #[test]
253    fn mode_conversions() {
254        let red = CellColor::rgb(255, 0, 0);
255        assert_eq!(red.as_ansi256(), Some(196));
256        let ansi = CellColor::ansi256(196);
257        assert_eq!(ansi.as_rgb(), Some((255, 0, 0)));
258        assert_eq!(CellColor::DEFAULT.as_ansi256(), None);
259        assert_eq!(CellColor::DEFAULT.as_rgb(), None);
260        assert_eq!(ColorMode::default(), ColorMode::Ansi256);
261    }
262}