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
| Ps | Effect | Reset by |
|---|---|---|
0 | Reset all attributes | — |
1 | Bold (or "increased intensity") | 22 |
2 | Dim / faint | 22 |
3 | Italic | 23 |
4 | Underline | 24 |
5 | Slow blink | 25 |
6 | Rapid blink (rarely implemented) | 25 |
7 | Inverse (swap fg/bg) | 27 |
8 | Hidden / concealed | 28 |
9 | Strikethrough | 29 |
21 | Doubly underlined (ECMA-48) or "bold off" (some terminals) — ambiguous | 24 |
22 | Normal intensity (clears both bold and dim) | — |
23 | Not italic | — |
24 | Not underlined (clears all underline styles) | — |
25 | Not blinking | — |
27 | Not inverse | — |
28 | Not hidden | — |
29 | Not struck | — |
53 | Overline | 55 |
55 | Not overlined | — |
Warning:
22clears both bold and dim. A common bug is treating22as "bold off" only, leaving dim text stuck dim. Similarly,21is 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
| Ps | Foreground | Ps | Background |
|---|---|---|---|
| 30–37 | Black, Red, Green, Yellow, Blue, Magenta, Cyan, White | 40–47 | same |
| 90–97 | Bright versions | 100–107 | same |
| 39 | Default foreground | 49 | Default 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
#FF0000for index 1 has removed the user's ability to theme, which is the whole reason indexed colors exist. StoreColor::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;0muses semicolons, which means those are five separate parameters, indistinguishable fromSGR 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::Defaultis distinct fromColor::Indexed(7). "Default foreground" tracks the theme and inverts correctly; "white" does not. Conflating them makes light themes unreadable.Stylemust beCopyand small. Target 12–16 bytes. Measure withstd::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:
| Interaction | Behavior | Note |
|---|---|---|
Inverse (SGR 7) | Swap fg and bg at render time, not in the stored style | Storing swapped colors breaks SGR 27 and breaks selection highlighting, which also inverts |
| Bold + indexed color | Historically, 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 fg | Blend the foreground toward the background, or use a dimmer palette entry | No standard. Pick something and be consistent. |
Hidden (SGR 8) | Render foreground = background | Do not skip rendering — selection and copy must still see the character |
| Selection highlight | Also an inversion | If 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
- Implement all underline styles (
4:1through4:5) plusSGR 58underline color, and render the curly one as an actual sine wave. This is what editors use for error squiggles. - Make
bold → brightconfigurable and add a test proving the difference is visible. - Implement
OSC 4(query/set a palette entry) andOSC 104(reset). Programs use these to theme themselves. - Benchmark the SGR path. Feed a 10 MB colorized log and profile. SGR is the hottest sequence in
existence; if
handle_sgrallocates, you will see it. - Implement
DECRQSS(DCS $ q m ST) so a program can query the current SGR state. - Add a color-blind-friendly palette and a runtime toggle, to prove the theme boundary is real:
if
Color::Indexedwere resolved in the core, this would be impossible.
Validation / Self-check
- Name the three color models and their exact sequences.
- Why is
SGR 38;2;r;g;bambiguous, and what does the colon form fix? - How many parameters does
38consume in the semicolon form? In the colon form? - What does
SGR 22clear, and what is the common bug? - Why must
Color::Defaultbe distinct fromColor::Indexed(7)? - Why must inverse be applied at render time rather than stored?
- Compute the 256-palette index for pure green in the cube, and the RGB for index 244.
- Why must
Stylebe small andCopy? What is the memory cost of one extra byte? - What must happen with an unknown SGR parameter, and why?
- Your terminal renders
SGR 1;31identically toSGR 91. Is that a bug? Explain.
Next: Modes.