SGR and Color

CSI Pm m — Select Graphic Rendition — is the sequence that carries every visual attribute: bold, italic, underline, inverse, and all three color models. It is the most frequently emitted escape sequence in existence, and it has the most accumulated cruft.


The Attribute Parameters

PsEffectReset by
0Reset all attributes—
1Bold (or "increased intensity")22
2Dim / faint22
3Italic23
4Underline24
5Slow blink25
6Rapid blink (rarely implemented)25
7Inverse (swap fg/bg)27
8Hidden / concealed28
9Strikethrough29
21Doubly underlined (ECMA-48) or "bold off" (some terminals) — ambiguous24
22Normal intensity (clears both bold and dim)—
23Not italic—
24Not underlined (clears all underline styles)—
25Not blinking—
27Not inverse—
28Not hidden—
29Not struck—
53Overline55
55Not overlined—

Warning: 22 clears both bold and dim. A common bug is treating 22 as "bold off" only, leaving dim text stuck dim. Similarly, 21 is genuinely ambiguous — ECMA-48 says double underline, several terminals historically used it for bold-off. Implement it as double underline (the modern consensus) and document the choice.


Color: Three Models

Model 1 — the 16 ANSI colors

PsForegroundPsBackground
30–37Black, Red, Green, Yellow, Blue, Magenta, Cyan, White40–47same
90–97Bright versions100–107same
39Default foreground49Default background
for i in 30 31 32 33 34 35 36 37; do printf "\033[${i}mcolor$i\033[0m "; done; echo
for i in 90 91 92 93 94 95 96 97; do printf "\033[${i}mbright$i\033[0m "; done; echo

Note: These are indices into a palette, not colors. Index 1 is "whatever the user's theme calls red." A terminal that hardcodes #FF0000 for index 1 has removed the user's ability to theme, which is the whole reason indexed colors exist. Store Color::Indexed(1), resolve to RGB only in the renderer.

Model 2 — the 256-color palette

   CSI 38 ; 5 ; n m     foreground = palette index n
   CSI 48 ; 5 ; n m     background = palette index n

   The palette layout:
     0-7     the 8 standard colors
     8-15    the 8 bright colors
     16-231  a 6×6×6 RGB cube:   index = 16 + 36*r + 6*g + b   where r,g,b ∈ 0..5
     232-255 a 24-step grayscale ramp (232 = darkest, 255 = lightest)
#![allow(unused)]
fn main() {
/// Convert a 256-palette index to RGB, for the renderer's default theme.
/// The 6-level cube uses the standard non-linear steps — NOT n*51, which is a
/// common approximation that makes every dark color visibly wrong.
fn palette_to_rgb(index: u8, theme: &Theme) -> Rgb {
    match index {
        0..=15 => theme.ansi[index as usize],            // user-themeable
        16..=231 => {
            let i = index - 16;
            const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
            Rgb {
                r: LEVELS[(i / 36) as usize],
                g: LEVELS[((i % 36) / 6) as usize],
                b: LEVELS[(i % 6) as usize],
            }
        }
        232..=255 => {
            let v = 8 + (index - 232) * 10;              // 8, 18, 28, ... 238
            Rgb { r: v, g: v, b: v }
        }
    }
}
}
# The whole palette:
for i in $(seq 0 255); do
  printf "\033[48;5;%dm %3d \033[0m" "$i" "$i"
  [ $(( (i+1) % 16 )) -eq 0 ] && echo
done

Model 3 — direct (24-bit "truecolor")

   CSI 38 ; 2 ; r ; g ; b m       foreground = RGB
   CSI 48 ; 2 ; r ; g ; b m       background = RGB

   The COLON form, per ITU-T T.416, includes a color-space id:
   CSI 38 : 2 : <cs> : r : g : b m
   where <cs> is almost always EMPTY:
   CSI 38 : 2 : : 255 : 0 : 0 m
printf '\033[38;2;255;100;0mtruecolor semicolon\033[0m\n'
printf '\033[38:2::255:100:0mtruecolor colon\033[0m\n'
# A gradient, to check you really have 24-bit:
for i in $(seq 0 79); do printf "\033[48;2;%d;0;%dm " $((i*3)) $((255-i*3)); done; printf '\033[0m\n'

Warning — the semicolon/colon problem. CSI 38;2;255;0;0m uses semicolons, which means those are five separate parameters, indistinguishable from SGR 38, SGR 2 (dim!), SGR 255, … to a parser that does not special-case 38/48. The colon form is unambiguous and is what the standard actually specifies — but the semicolon form is what everything emits. You must support both, and your parameter iterator must special-case 38/48/58 to consume the right number of following parameters.


The Parsing Algorithm

SGR is a sequential interpretation of parameters, and 38/48/58 consume extras. Get this loop right and SGR is done.

#![allow(unused)]
fn main() {
fn handle_sgr(&mut self, params: &Params) {
    // No parameters at all means SGR 0 (reset).
    if params.is_empty() {
        self.cursor.style = Style::default();
        return;
    }

    let mut i = 0;
    while i < params.len() {
        // The COLON form arrives as one parameter with sub-parameters, so check
        // that first: `38:5:196` is params[i] == [38, 5, 196].
        let sub = params.subparams(i);
        if sub.len() > 1 {
            match sub[0] {
                38 => self.cursor.style.fg = parse_extended_color(sub).unwrap_or(self.cursor.style.fg),
                48 => self.cursor.style.bg = parse_extended_color(sub).unwrap_or(self.cursor.style.bg),
                58 => self.cursor.style.underline_color = parse_extended_color(sub),
                4 => self.cursor.style.underline = UnderlineStyle::from(sub[1]), // 4:3 = curly
                _ => {}
            }
            i += 1;
            continue;
        }

        match params.get_or(i, 0) {
            0 => self.cursor.style = Style::default(),
            1 => self.cursor.style.flags.insert(Attr::BOLD),
            2 => self.cursor.style.flags.insert(Attr::DIM),
            3 => self.cursor.style.flags.insert(Attr::ITALIC),
            4 => self.cursor.style.underline = UnderlineStyle::Single,
            5 | 6 => self.cursor.style.flags.insert(Attr::BLINK),
            7 => self.cursor.style.flags.insert(Attr::INVERSE),
            8 => self.cursor.style.flags.insert(Attr::HIDDEN),
            9 => self.cursor.style.flags.insert(Attr::STRIKE),
            21 => self.cursor.style.underline = UnderlineStyle::Double,
            // 22 clears BOTH bold and dim — a very common bug is clearing only bold.
            22 => self.cursor.style.flags.remove(Attr::BOLD | Attr::DIM),
            23 => self.cursor.style.flags.remove(Attr::ITALIC),
            24 => self.cursor.style.underline = UnderlineStyle::None,
            25 => self.cursor.style.flags.remove(Attr::BLINK),
            27 => self.cursor.style.flags.remove(Attr::INVERSE),
            28 => self.cursor.style.flags.remove(Attr::HIDDEN),
            29 => self.cursor.style.flags.remove(Attr::STRIKE),
            30..=37 => self.cursor.style.fg = Color::Indexed((params.get_or(i, 0) - 30) as u8),
            // The SEMICOLON form: 38 consumes the FOLLOWING parameters.
            38 => {
                let (color, used) = parse_extended_color_semicolon(params, i);
                if let Some(c) = color { self.cursor.style.fg = c; }
                i += used;         // skip what we consumed
                continue;
            }
            39 => self.cursor.style.fg = Color::Default,
            40..=47 => self.cursor.style.bg = Color::Indexed((params.get_or(i, 0) - 40) as u8),
            48 => {
                let (color, used) = parse_extended_color_semicolon(params, i);
                if let Some(c) = color { self.cursor.style.bg = c; }
                i += used;
                continue;
            }
            49 => self.cursor.style.bg = Color::Default,
            53 => self.cursor.style.flags.insert(Attr::OVERLINE),
            55 => self.cursor.style.flags.remove(Attr::OVERLINE),
            58 => {
                let (color, used) = parse_extended_color_semicolon(params, i);
                self.cursor.style.underline_color = color;
                i += used;
                continue;
            }
            59 => self.cursor.style.underline_color = None,
            90..=97 => self.cursor.style.fg = Color::Indexed((params.get_or(i, 0) - 90 + 8) as u8),
            100..=107 => self.cursor.style.bg = Color::Indexed((params.get_or(i, 0) - 100 + 8) as u8),
            // Unknown parameters are IGNORED, not fatal. Forward compatibility.
            _ => {}
        }
        i += 1;
    }
}

/// Semicolon form: 38;5;n (3 params) or 38;2;r;g;b (5 params).
/// Returns the color and how many parameters were consumed.
fn parse_extended_color_semicolon(params: &Params, i: usize) -> (Option<Color>, usize) {
    match params.get_or(i + 1, 0) {
        5 => (Some(Color::Indexed(params.get_or(i + 2, 0) as u8)), 3),
        2 => {
            let r = params.get_or(i + 2, 0).min(255) as u8;
            let g = params.get_or(i + 3, 0).min(255) as u8;
            let b = params.get_or(i + 4, 0).min(255) as u8;
            (Some(Color::Rgb(r, g, b)), 5)
        }
        // Malformed: skip just the 38 and continue. Do NOT abort the whole SGR.
        _ => (None, 1),
    }
}
}

The Style Type

#![allow(unused)]
fn main() {
/// Per-cell style. MUST be small and Copy — it is stored in every cell, and an
/// 80×24 screen has 1,920 of them. A String in here (e.g. a hyperlink URI)
/// would put a heap allocation in every cell.
#[derive(Copy, Clone, PartialEq, Eq, Default)]
pub struct Style {
    pub fg: Color,                        // 4 bytes
    pub bg: Color,                        // 4 bytes
    pub underline_color: Option<Color>,   // 5 bytes → pack it
    pub underline: UnderlineStyle,        // 1 byte
    pub flags: Attr,                      // 1 byte (bitflags)
    /// Interned hyperlink id — NOT the URI itself. See the OSC chapter.
    pub hyperlink: u16,                   // 0 = none
}

#[derive(Copy, Clone, PartialEq, Eq, Default)]
pub enum Color {
    #[default]
    Default,             // "whatever the theme says"
    Indexed(u8),         // 0-255, resolved by the renderer's theme
    Rgb(u8, u8, u8),     // direct
}

#[derive(Copy, Clone, PartialEq, Eq, Default)]
pub enum UnderlineStyle {
    #[default] None,
    Single,      // SGR 4    or 4:1
    Double,      // SGR 21   or 4:2
    Curly,       // SGR 4:3  — the squiggly underline editors use for errors
    Dotted,      // SGR 4:4
    Dashed,      // SGR 4:5
}
}

Design points:

  • Color::Default is distinct from Color::Indexed(7). "Default foreground" tracks the theme and inverts correctly; "white" does not. Conflating them makes light themes unreadable.
  • Style must be Copy and small. Target 12–16 bytes. Measure with std::mem::size_of::<Style>() in a test.
  • The hyperlink is an interned id, not a String. See OSC and String Sequences.
#![allow(unused)]
fn main() {
#[test]
fn style_stays_small() {
    // A regression guard. Style is stored per cell; growth here is multiplied by
    // rows × cols × scrollback.
    assert!(std::mem::size_of::<Style>() <= 16,
            "Style grew to {} bytes", std::mem::size_of::<Style>());
}
}

Inverse, Bold, and the Rendering Interactions

Three interactions that are the terminal's job to define, and that surprise people:

InteractionBehaviorNote
Inverse (SGR 7)Swap fg and bg at render time, not in the stored styleStoring swapped colors breaks SGR 27 and breaks selection highlighting, which also inverts
Bold + indexed colorHistorically, bold made colors 0–7 render as 8–15 (bright)Modern terminals mostly use a bold font. Make it configurable; xterm's boldColors default is "yes". Programs still rely on it.
Dim + default fgBlend the foreground toward the background, or use a dimmer palette entryNo standard. Pick something and be consistent.
Hidden (SGR 8)Render foreground = backgroundDo not skip rendering — selection and copy must still see the character
Selection highlightAlso an inversionIf inverse is baked into the cell, selected inverse text renders wrong
#![allow(unused)]
fn main() {
/// Resolve a cell's stored style to concrete colors, at render time.
/// Inverse and selection are applied HERE, never stored in the cell.
fn resolve_colors(style: &Style, selected: bool, theme: &Theme) -> (Rgb, Rgb) {
    let mut fg = theme.resolve(style.fg, theme.foreground);
    let mut bg = theme.resolve(style.bg, theme.background);

    if style.flags.contains(Attr::BOLD) && theme.bold_is_bright {
        if let Color::Indexed(i @ 0..=7) = style.fg { fg = theme.ansi[(i + 8) as usize]; }
    }
    if style.flags.contains(Attr::DIM) { fg = blend(fg, bg, 0.5); }
    if style.flags.contains(Attr::INVERSE) { std::mem::swap(&mut fg, &mut bg); }
    if style.flags.contains(Attr::HIDDEN) { fg = bg; }
    if selected { std::mem::swap(&mut fg, &mut bg); }   // composes correctly with INVERSE
    (fg, bg)
}
}

Experiment

CLAIM. SGR is stateful and applies to subsequently written cells, not retroactively — and failing to reset it leaks style into everything after.

METHOD.

# 1. Statefulness.
printf 'plain \033[31mred still-red \033[0mplain again\n'

# 2. The leak. Note there is no reset:
printf '\033[41;33myellow on red'
printf ' ... and now your prompt is also yellow on red\n'
printf '\033[0m'          # you have to fix it yourself

# 3. Attribute combinations.
for a in 1 2 3 4 5 7 9 53; do printf "\033[${a}mSGR $a\033[0m  "; done; echo

# 4. Bold + color interaction — does bold brighten your colors?
printf '\033[31mred\033[0m  \033[1;31mbold red\033[0m  \033[91mbright red\033[0m\n'
#    If "bold red" matches "bright red", your terminal has boldColors on.

# 5. Curly underline (SGR 4:3) — used by editors for spell/error marks.
printf '\033[4:3;58;5;196mcurly red underline\033[0m\n'

# 6. Does your terminal actually do truecolor, or is it quantizing?
awk 'BEGIN{ for(i=0;i<256;i++) printf "\033[48;2;%d;0;0m ", i; print "\033[0m" }'
#    A smooth gradient = truecolor. Visible banding = 256-color quantization.

PREDICTION. Before running #4: will "bold red" look the same as "bright red" in your terminal? Before running #6: smooth or banded?


Test

#![allow(unused)]
fn main() {
#[test]
fn sgr_0_resets_everything() {
    let mut t = Terminal::new(3, 20);
    t.advance(b"\x1b[1;3;4;7;31;42m\x1b[0m");
    assert_eq!(t.cursor().style, Style::default());
}

#[test]
fn sgr_22_clears_both_bold_and_dim() {
    // The common bug: treating 22 as "bold off" only, leaving text stuck dim.
    let mut t = Terminal::new(3, 20);
    t.advance(b"\x1b[1;2m\x1b[22m");
    assert!(!t.cursor().style.flags.contains(Attr::BOLD));
    assert!(!t.cursor().style.flags.contains(Attr::DIM));
}

#[test]
fn extended_color_semicolon_and_colon_forms_agree() {
    // These must produce identical styles. A parser that flattens colons, or one
    // that does not special-case 38's parameter consumption, fails this.
    let mut a = Terminal::new(3, 20);
    let mut b = Terminal::new(3, 20);
    a.advance(b"\x1b[38;2;255;100;0mX");
    b.advance(b"\x1b[38:2::255:100:0mX");
    assert_eq!(a.screen().row(0).cell(0).style().fg, Color::Rgb(255, 100, 0));
    assert_eq!(a.screen().row(0).cell(0).style(), b.screen().row(0).cell(0).style());
}

#[test]
fn semicolon_38_consumes_the_right_number_of_parameters() {
    // "38;5;196;1" must set the fg to palette 196 AND set bold — proving the
    // consumption count is right. Getting it wrong makes 196 parse as SGR 196.
    let mut t = Terminal::new(3, 20);
    t.advance(b"\x1b[38;5;196;1mX");
    assert_eq!(t.cursor().style.fg, Color::Indexed(196));
    assert!(t.cursor().style.flags.contains(Attr::BOLD));
}

#[test]
fn unknown_sgr_parameters_are_ignored_not_fatal() {
    // Forward compatibility: an unknown parameter must not abort the rest.
    let mut t = Terminal::new(3, 20);
    t.advance(b"\x1b[1;9999;31mX");
    assert!(t.cursor().style.flags.contains(Attr::BOLD));
    assert_eq!(t.cursor().style.fg, Color::Indexed(1));
}

#[test]
fn default_color_is_not_the_same_as_indexed_seven() {
    // Conflating them breaks light themes and breaks inverse.
    let mut t = Terminal::new(3, 20);
    t.advance(b"\x1b[37mA\x1b[39mB");
    assert_eq!(t.screen().row(0).cell(0).style().fg, Color::Indexed(7));
    assert_eq!(t.screen().row(0).cell(1).style().fg, Color::Default);
}

#[test]
fn inverse_is_not_baked_into_the_stored_style() {
    // Storing swapped colors breaks SGR 27 and breaks selection highlighting.
    let mut t = Terminal::new(3, 20);
    t.advance(b"\x1b[31;42;7mX\x1b[27mY");
    let x = t.screen().row(0).cell(0).style();
    assert_eq!(x.fg, Color::Indexed(1), "the STORED fg is still red");
    assert!(x.flags.contains(Attr::INVERSE), "inverse is a flag, not a swap");
}

#[test]
fn rgb_components_are_clamped() {
    let mut t = Terminal::new(3, 20);
    t.advance(b"\x1b[38;2;999;300;0mX");
    assert_eq!(t.cursor().style.fg, Color::Rgb(255, 255, 0));
}
}

Challenge Extensions

  1. Implement all underline styles (4:1 through 4:5) plus SGR 58 underline color, and render the curly one as an actual sine wave. This is what editors use for error squiggles.
  2. Make bold → bright configurable and add a test proving the difference is visible.
  3. Implement OSC 4 (query/set a palette entry) and OSC 104 (reset). Programs use these to theme themselves.
  4. Benchmark the SGR path. Feed a 10 MB colorized log and profile. SGR is the hottest sequence in existence; if handle_sgr allocates, you will see it.
  5. Implement DECRQSS (DCS $ q m ST) so a program can query the current SGR state.
  6. Add a color-blind-friendly palette and a runtime toggle, to prove the theme boundary is real: if Color::Indexed were resolved in the core, this would be impossible.

Validation / Self-check

  1. Name the three color models and their exact sequences.
  2. Why is SGR 38;2;r;g;b ambiguous, and what does the colon form fix?
  3. How many parameters does 38 consume in the semicolon form? In the colon form?
  4. What does SGR 22 clear, and what is the common bug?
  5. Why must Color::Default be distinct from Color::Indexed(7)?
  6. Why must inverse be applied at render time rather than stored?
  7. Compute the 256-palette index for pure green in the cube, and the RGB for index 244.
  8. Why must Style be small and Copy? What is the memory cost of one extra byte?
  9. What must happen with an unknown SGR parameter, and why?
  10. Your terminal renders SGR 1;31 identically to SGR 91. Is that a bug? Explain.

Next: Modes.