Skip to main content

retroglyph_core/color/
sgr.rs

1//! Select Graphic Rendition (SGR) ANSI encoding, shared by every text-terminal encoder in this
2//! workspace.
3//!
4//! [`Headless::format_styled`](crate::backend::Headless::format_styled) (styled snapshots) and
5//! `retroglyph-recorder`'s `write_cast` (asciicast export) both need "the SGR codes for this
6//! [`Style`]," and neither should carry its own copy.
7//!
8//! Codes follow ECMA-48 5th ed. section 8.3.117: 30-37/40-47 for the standard foreground/
9//! background colors, 90-97/100-107 for the bright variants, `38;5;n`/`48;5;n` for 256-color
10//! indices, and `38;2;r;g;b`/`48;2;r;g;b` for 24-bit truecolor.
11//! (<https://www.ecma-international.org/publications-and-standards/standards/ecma-48/>)
12
13use super::{Color, Style};
14use alloc::string::String;
15use core::fmt::Write as _;
16
17/// Appends the SGR codes for `style`'s non-default foreground/background to `out`, as a
18/// single `\x1b[...m` sequence.
19///
20/// A `Color::Default` channel is left unset, relying on the caller's preceding `\x1b[0m`
21/// reset rather than emitting an explicit `39`/`49` reset code. Emits nothing at all when
22/// both channels are `Color::Default`.
23pub fn push_sgr(out: &mut String, style: Style) {
24    let mut params = String::new();
25    if let Some(code) = sgr_color(style.foreground(), false) {
26        let _ = write!(params, "{code}");
27    }
28    if let Some(code) = sgr_color(style.background(), true) {
29        if !params.is_empty() {
30            params.push(';');
31        }
32        let _ = write!(params, "{code}");
33    }
34    if !params.is_empty() {
35        let _ = write!(out, "\x1b[{params}m");
36    }
37}
38
39/// The SGR parameter string for `color` in the foreground (`bg: false`) or background
40/// (`bg: true`) slot, or `None` for `Color::Default` (nothing to emit).
41///
42/// Codes follow ECMA-48 SGR (see this module's file-level docs): 30/40 base for standard
43/// colors, 90/100 for bright, offset by the color index within its group of 8.
44#[must_use]
45pub fn sgr_color(color: Color, bg: bool) -> Option<String> {
46    match color {
47        Color::Default => None,
48        Color::Ansi(ansi) => {
49            let index = ansi.to_index();
50            let base = match (index < 8, bg) {
51                (true, false) => 30,
52                (true, true) => 40,
53                (false, false) => 90,
54                (false, true) => 100,
55            };
56            Some(alloc::format!("{}", base + index % 8))
57        }
58        Color::Indexed(index) => Some(alloc::format!("{};5;{index}", if bg { 48 } else { 38 })),
59        Color::Rgb { r, g, b } => {
60            Some(alloc::format!("{};2;{r};{g};{b}", if bg { 48 } else { 38 }))
61        }
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use crate::color::AnsiColor;
69    use alloc::borrow::ToOwned as _;
70
71    #[test]
72    fn sgr_color_is_none_for_default() {
73        assert_eq!(sgr_color(Color::Default, false), None);
74        assert_eq!(sgr_color(Color::Default, true), None);
75    }
76
77    #[test]
78    fn sgr_color_standard_ansi_uses_30_40_base() {
79        assert_eq!(
80            sgr_color(Color::Ansi(AnsiColor::Red), false),
81            Some("31".to_owned())
82        );
83        assert_eq!(
84            sgr_color(Color::Ansi(AnsiColor::Red), true),
85            Some("41".to_owned())
86        );
87    }
88
89    #[test]
90    fn sgr_color_bright_ansi_uses_90_100_base() {
91        assert_eq!(
92            sgr_color(Color::Ansi(AnsiColor::BrightRed), false),
93            Some("91".to_owned())
94        );
95        assert_eq!(
96            sgr_color(Color::Ansi(AnsiColor::BrightRed), true),
97            Some("101".to_owned())
98        );
99    }
100
101    #[test]
102    fn sgr_color_indexed_and_rgb() {
103        assert_eq!(
104            sgr_color(Color::Indexed(200), false),
105            Some("38;5;200".to_owned())
106        );
107        assert_eq!(
108            sgr_color(Color::Indexed(200), true),
109            Some("48;5;200".to_owned())
110        );
111        assert_eq!(
112            sgr_color(Color::rgb(1, 2, 3), false),
113            Some("38;2;1;2;3".to_owned())
114        );
115        assert_eq!(
116            sgr_color(Color::rgb(1, 2, 3), true),
117            Some("48;2;1;2;3".to_owned())
118        );
119    }
120
121    #[test]
122    fn push_sgr_emits_nothing_for_a_fully_default_style() {
123        let mut out = String::new();
124        push_sgr(&mut out, Style::default());
125        assert_eq!(out, "");
126    }
127
128    #[test]
129    fn push_sgr_combines_fg_and_bg_into_one_escape() {
130        let mut out = String::new();
131        push_sgr(
132            &mut out,
133            Style::new()
134                .fg(Color::Ansi(AnsiColor::Red))
135                .bg(Color::Ansi(AnsiColor::Blue)),
136        );
137        assert_eq!(out, "\x1b[31;44m");
138    }
139
140    #[test]
141    fn push_sgr_emits_only_the_non_default_channel() {
142        let mut out = String::new();
143        push_sgr(&mut out, Style::new().bg(Color::Ansi(AnsiColor::Blue)));
144        assert_eq!(out, "\x1b[44m");
145    }
146}