Skip to main content

nmbrs_runtime/readouts/
color.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Colour and style resolution for readout body grammar
5//! (SRD-63 §5.2).
6//!
7//! Three accept-shapes:
8//!
9//! 1. **Direct colour names** — uppercase tokens like
10//!    `RED`, `BLUE`, `BRIGHT_GREEN`, `DIM`. Map to ANSI
11//!    SGR codes directly.
12//! 2. **Hex** — `#RRGGBB` or `#RGB`. Map to ANSI
13//!    truecolor (`\x1b[38;2;r;g;bm`).
14//! 3. **Style names** — semantic, palette-resolved tokens
15//!    like `ERROR`, `WARN`, `INFO`, `OK`, `HEADER`,
16//!    `MUTED`. Resolve through the active palette
17//!    (default `WONG`). Mapping is consistent across
18//!    surfaces — the same `ERROR` style produces the same
19//!    visual emphasis whether it lands in a readout, a
20//!    plot legend (SRD-46), or a TUI panel.
21
22/// Parsed colour or style spec. Carries enough information
23/// to produce an ANSI escape on demand without re-parsing.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum ColorSpec {
26    /// Named colour (mapped through `direct_color_ansi`).
27    Direct(&'static str),
28    /// Bright variant — `BRIGHT_RED` / etc.
29    Bright(&'static str),
30    /// Truecolor RGB (from a hex literal).
31    Rgb(u8, u8, u8),
32    /// Semantic style — resolved through the active
33    /// palette at render time.
34    Style(StyleName),
35    /// `DIM` modifier — applies the SGR dim code on top
36    /// of whatever foreground is in play. Treated as a
37    /// standalone "color" in the grammar.
38    Dim,
39}
40
41/// Semantic style names. Each maps to a palette entry per
42/// the active palette (default `WONG`); operators / future
43/// workloads can swap palettes.
44#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
45pub enum StyleName {
46    Error,
47    Warn,
48    Info,
49    Ok,
50    Header,
51    Subhead,
52    Emphasis,
53    Muted,
54}
55
56impl StyleName {
57    /// Map an uppercase identifier to a `StyleName`.
58    pub fn parse(s: &str) -> Option<Self> {
59        Some(match s {
60            "ERROR" => StyleName::Error,
61            "WARN" => StyleName::Warn,
62            "INFO" => StyleName::Info,
63            "OK" => StyleName::Ok,
64            "HEADER" => StyleName::Header,
65            "SUBHEAD" => StyleName::Subhead,
66            "EMPHASIS" => StyleName::Emphasis,
67            "MUTED" => StyleName::Muted,
68            _ => return None,
69        })
70    }
71
72    /// Resolve through the active palette to a concrete
73    /// `ColorSpec`. Push 4 ships only the `Wong` palette;
74    /// future revisions will add more and a runtime
75    /// switch.
76    pub fn resolve(self, palette: Palette) -> ColorSpec {
77        match (palette, self) {
78            // Wong palette — colorblind-safe palette already
79            // used by SRD-46 plot rendering. Mapping picked
80            // for consistent semantic emphasis across
81            // surfaces.
82            (Palette::Wong, StyleName::Error) => ColorSpec::Rgb(214, 40, 40),
83            (Palette::Wong, StyleName::Warn) => ColorSpec::Rgb(247, 201, 72),
84            (Palette::Wong, StyleName::Info) => ColorSpec::Rgb(77, 201, 246),
85            (Palette::Wong, StyleName::Ok) => ColorSpec::Rgb(122, 193, 66),
86            (Palette::Wong, StyleName::Header) => ColorSpec::Rgb(255, 255, 255),
87            (Palette::Wong, StyleName::Subhead) => ColorSpec::Rgb(180, 180, 180),
88            (Palette::Wong, StyleName::Emphasis) => ColorSpec::Bright("WHITE"),
89            (Palette::Wong, StyleName::Muted) => ColorSpec::Dim,
90        }
91    }
92}
93
94/// Active palette. Default is `Wong` (colorblind-safe);
95/// matches the palette name SRD-46 reports default to.
96#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
97pub enum Palette {
98    #[default]
99    Wong,
100}
101
102impl ColorSpec {
103    /// Parse a token like `RED`, `BRIGHT_GREEN`,
104    /// `#7AC166`, `#FFF`, or `ERROR`. Returns `None` for
105    /// unknown tokens — caller decides whether that's an
106    /// error.
107    pub fn parse(token: &str) -> Option<Self> {
108        if let Some(hex) = token.strip_prefix('#') {
109            return parse_hex(hex).map(|(r, g, b)| ColorSpec::Rgb(r, g, b));
110        }
111        // Try `BRIGHT_<NAME>` first.
112        if let Some(name) = token.strip_prefix("BRIGHT_")
113            && direct_color_lookup(name).is_some()
114        {
115            return Some(ColorSpec::Bright(direct_color_canon(name)?));
116        }
117        if direct_color_lookup(token).is_some() {
118            return Some(ColorSpec::Direct(direct_color_canon(token)?));
119        }
120        if token == "DIM" {
121            return Some(ColorSpec::Dim);
122        }
123        StyleName::parse(token).map(ColorSpec::Style)
124    }
125
126    /// Emit the ANSI SGR escape that begins this colour.
127    /// Empty string when colour is disabled at the surface.
128    pub fn ansi_open(&self, palette: Palette, color_enabled: bool) -> String {
129        if !color_enabled {
130            return String::new();
131        }
132        match self {
133            ColorSpec::Direct(name) => {
134                format!("\x1b[{}m", direct_color_lookup(name).unwrap_or(0))
135            }
136            ColorSpec::Bright(name) => {
137                let base = direct_color_lookup(name).unwrap_or(0);
138                // Bright variants are base + 60 in the
139                // 30..=37 / 40..=47 family. Foreground
140                // codes only — readout text emits to FG.
141                format!("\x1b[{}m", base + 60)
142            }
143            ColorSpec::Rgb(r, g, b) => {
144                format!("\x1b[38;2;{r};{g};{b}m")
145            }
146            ColorSpec::Style(s) => s.resolve(palette).ansi_open(palette, color_enabled),
147            ColorSpec::Dim => "\x1b[2m".to_string(),
148        }
149    }
150
151    /// SGR reset.
152    pub fn ansi_close(&self, color_enabled: bool) -> &'static str {
153        if !color_enabled { "" } else { "\x1b[0m" }
154    }
155}
156
157/// Map a colour name to its ANSI SGR foreground code.
158/// Returns the standard 30..=37 range for the eight base
159/// colours; bright variants are `base + 60`.
160fn direct_color_lookup(name: &str) -> Option<u8> {
161    Some(match name {
162        "BLACK" => 30,
163        "RED" => 31,
164        "GREEN" => 32,
165        "YELLOW" => 33,
166        "BLUE" => 34,
167        "MAGENTA" => 35,
168        "CYAN" => 36,
169        "WHITE" => 37,
170        _ => return None,
171    })
172}
173
174fn direct_color_canon(name: &str) -> Option<&'static str> {
175    Some(match name {
176        "BLACK" => "BLACK",
177        "RED" => "RED",
178        "GREEN" => "GREEN",
179        "YELLOW" => "YELLOW",
180        "BLUE" => "BLUE",
181        "MAGENTA" => "MAGENTA",
182        "CYAN" => "CYAN",
183        "WHITE" => "WHITE",
184        _ => return None,
185    })
186}
187
188/// Parse `RRGGBB` or `RGB` (without leading `#`) into a
189/// `(r, g, b)` triple.
190fn parse_hex(s: &str) -> Option<(u8, u8, u8)> {
191    match s.len() {
192        6 => {
193            let r = u8::from_str_radix(&s[0..2], 16).ok()?;
194            let g = u8::from_str_radix(&s[2..4], 16).ok()?;
195            let b = u8::from_str_radix(&s[4..6], 16).ok()?;
196            Some((r, g, b))
197        }
198        3 => {
199            // CSS-style 3-char shorthand: `#abc` → `#aabbcc`.
200            let r = u8::from_str_radix(&s[0..1], 16).ok()? * 0x11;
201            let g = u8::from_str_radix(&s[1..2], 16).ok()? * 0x11;
202            let b = u8::from_str_radix(&s[2..3], 16).ok()? * 0x11;
203            Some((r, g, b))
204        }
205        _ => None,
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn parses_direct_color_names() {
215        assert_eq!(ColorSpec::parse("RED"), Some(ColorSpec::Direct("RED")));
216        assert_eq!(
217            ColorSpec::parse("YELLOW"),
218            Some(ColorSpec::Direct("YELLOW"))
219        );
220        assert_eq!(ColorSpec::parse("BLACK"), Some(ColorSpec::Direct("BLACK")));
221    }
222
223    #[test]
224    fn parses_bright_variants() {
225        assert_eq!(
226            ColorSpec::parse("BRIGHT_RED"),
227            Some(ColorSpec::Bright("RED"))
228        );
229        assert_eq!(
230            ColorSpec::parse("BRIGHT_WHITE"),
231            Some(ColorSpec::Bright("WHITE"))
232        );
233    }
234
235    #[test]
236    fn parses_dim() {
237        assert_eq!(ColorSpec::parse("DIM"), Some(ColorSpec::Dim));
238    }
239
240    #[test]
241    fn parses_hex_long_and_short() {
242        assert_eq!(
243            ColorSpec::parse("#7AC166"),
244            Some(ColorSpec::Rgb(0x7A, 0xC1, 0x66))
245        );
246        assert_eq!(
247            ColorSpec::parse("#FFF"),
248            Some(ColorSpec::Rgb(0xFF, 0xFF, 0xFF))
249        );
250    }
251
252    #[test]
253    fn parses_style_names() {
254        assert_eq!(
255            ColorSpec::parse("ERROR"),
256            Some(ColorSpec::Style(StyleName::Error))
257        );
258        assert_eq!(
259            ColorSpec::parse("INFO"),
260            Some(ColorSpec::Style(StyleName::Info))
261        );
262    }
263
264    #[test]
265    fn unknown_tokens_return_none() {
266        assert_eq!(ColorSpec::parse("not_a_color"), None);
267        assert_eq!(ColorSpec::parse("#GG0000"), None);
268        assert_eq!(ColorSpec::parse("#1234"), None);
269    }
270
271    #[test]
272    fn ansi_open_emits_correct_sgr() {
273        assert_eq!(
274            ColorSpec::Direct("RED").ansi_open(Palette::Wong, true),
275            "\x1b[31m",
276        );
277        assert_eq!(
278            ColorSpec::Bright("RED").ansi_open(Palette::Wong, true),
279            "\x1b[91m",
280        );
281        assert_eq!(
282            ColorSpec::Rgb(0x7A, 0xC1, 0x66).ansi_open(Palette::Wong, true),
283            "\x1b[38;2;122;193;102m",
284        );
285        assert_eq!(ColorSpec::Dim.ansi_open(Palette::Wong, true), "\x1b[2m",);
286    }
287
288    #[test]
289    fn ansi_disabled_emits_nothing() {
290        assert_eq!(ColorSpec::Direct("RED").ansi_open(Palette::Wong, false), "",);
291        assert_eq!(ColorSpec::Direct("RED").ansi_close(false), "");
292    }
293
294    #[test]
295    fn style_resolves_through_palette() {
296        let error = ColorSpec::Style(StyleName::Error);
297        let resolved = match error.clone() {
298            ColorSpec::Style(s) => s.resolve(Palette::Wong),
299            _ => unreachable!(),
300        };
301        assert!(matches!(resolved, ColorSpec::Rgb(214, 40, 40)));
302    }
303}