diff --git a/CHANGELOG.md b/CHANGELOG.md index 432f2f3..53fb074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - add `window.opacity` for a transparent background (compositor-dependent) +### Fixed +- honour SGR colon subparameters: `4:0` now turns underline off (was leaving the whole screen underlined) and `38:2:r:g:b` / `48:5:n` colours are applied + ## [0.10.0] - 2026-07-25 ### Added diff --git a/src/terminal/parser.rs b/src/terminal/parser.rs index 767fa1b..d3213be 100644 --- a/src/terminal/parser.rs +++ b/src/terminal/parser.rs @@ -56,6 +56,22 @@ fn sgr_should_reset(ps: &[u16]) -> bool { ps.is_empty() || (ps.len() == 1 && ps[0] == 0) } +/// Colon-form colour: `38:5:n`, `38:2:r:g:b` or `38:2::r:g:b` (with colour-space id). +fn parse_color_from_subparams(g: &[u16], palette: &[Color; 16]) -> Option { + match g.get(1)? { + 5 => g.get(2).map(|&n| color256(n as u8, palette)), + 2 => { + // 6 entries means the colour-space id slot is present and must be skipped. + let start = if g.len() >= 6 { 3 } else { 2 }; + let r = *g.get(start)? as u8; + let gr = *g.get(start + 1)? as u8; + let b = *g.get(start + 2)? as u8; + Some(Color::rgb(r, gr, b)) + } + _ => None, + } +} + struct Performer<'a> { grid: &'a mut Grid, dcs_kind: Option, @@ -158,19 +174,34 @@ impl Performer<'_> { } } - fn handle_sgr(&mut self, ps: &[u16]) { + /// `groups[i]` holds parameter `i` with its colon-separated subparameters; + /// `ps[i] == groups[i][0]` is the flat view used by the legacy semicolon forms. + fn handle_sgr(&mut self, ps: &[u16], groups: &[&[u16]]) { if sgr_should_reset(ps) { self.grid.reset_sgr(); return; } let mut i = 0; while i < ps.len() { + let sub = groups.get(i).map(|g| &g[..]).unwrap_or(&[]); match ps[i] { 0 => self.grid.reset_sgr(), + // `4:0` disables underline; `4:1`..`4:5` pick a style we render as a + // plain underline. A bare `4` (no subparameter) also enables it. + 4 => self.grid.underline = sub.get(1).copied().unwrap_or(1) != 0, + // Colon form of 38/48 — the whole colour lives in this one parameter. + 38 | 48 if sub.len() > 1 => { + if let Some(c) = parse_color_from_subparams(sub, &self.grid.palette) { + if ps[i] == 38 { + self.grid.fg = c; + } else { + self.grid.bg = c; + } + } + } 1 => self.grid.bold = true, 2 => self.grid.dim = true, 3 => self.grid.italic = true, - 4 => self.grid.underline = true, 5 => self.grid.blink = true, 7 => self.grid.reverse = true, 9 => self.grid.strikethrough = true, @@ -265,7 +296,8 @@ impl Perform for Performer<'_> { } fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) { - let ps: Vec = params.iter().map(|p| p[0]).collect(); + let groups: Vec<&[u16]> = params.iter().collect(); + let ps: Vec = groups.iter().map(|g| g[0]).collect(); let p0 = ps.first().copied().unwrap_or(0); let p1 = ps.get(1).copied().unwrap_or(0); @@ -292,7 +324,7 @@ impl Perform for Performer<'_> { } 'J' => self.handle_erase_display(p0), 'K' => self.handle_erase_line(p0), - 'm' => self.handle_sgr(&ps), + 'm' => self.handle_sgr(&ps, &groups), 'S' => self.grid.scroll_up(param_or_one(p0)), 'T' => self.grid.scroll_down(param_or_one(p0)), // Insert Line diff --git a/src/terminal/parser_test.rs b/src/terminal/parser_test.rs index 0bfa648..6487963 100644 --- a/src/terminal/parser_test.rs +++ b/src/terminal/parser_test.rs @@ -471,6 +471,63 @@ fn sgr_underline_off_code_24() { assert!(!p.grid.underline); } +#[test] +fn sgr_underline_style_subparam_enables() { + let mut p = make_parser(10, 5); + p.process(b"\x1b[4:3m"); // curly underline + assert!(p.grid.underline); +} + +#[test] +fn sgr_underline_subparam_zero_disables() { + let mut p = make_parser(10, 5); + p.process(b"\x1b[4:3m"); + p.process(b"\x1b[4:0m"); // style "none" turns underline off + assert!(!p.grid.underline); +} + +#[test] +fn sgr_underline_subparam_zero_disables_within_group() { + let mut p = make_parser(10, 5); + p.process(b"\x1b[4m"); + p.process(b"\x1b[1;4:0;3m"); // mixed params around the colon form + assert!(!p.grid.underline); + assert!(p.grid.bold); + assert!(p.grid.italic); +} + +#[test] +fn sgr_underline_colon_style_does_not_leak_into_italic() { + let mut p = make_parser(10, 5); + p.process(b"\x1b[4:3m"); // the ":3" must not be read as SGR 3 + assert!(!p.grid.italic); +} + +#[test] +fn sgr_truecolor_colon_form_foreground() { + let mut p = make_parser(10, 5); + p.process(b"\x1b[38:2:255:128:0m"); + p.process(b"X"); + assert_eq!(p.grid.cell(0, 0).fg, Color::rgb(255, 128, 0)); +} + +#[test] +fn sgr_truecolor_colon_form_with_colorspace_id() { + let mut p = make_parser(10, 5); + p.process(b"\x1b[38:2::255:128:0m"); + p.process(b"X"); + assert_eq!(p.grid.cell(0, 0).fg, Color::rgb(255, 128, 0)); +} + +#[test] +fn sgr_256_colon_form_background() { + let mut p = make_parser(10, 5); + p.process(b"\x1b[48:5:21m"); + p.process(b"X"); + let expected = color256(21, &[Color::BLACK; 16]); + assert_eq!(p.grid.cell(0, 0).bg, expected); +} + #[test] fn sgr_256_color_foreground() { let mut p = make_parser(10, 5); diff --git a/src/terminal/scenario_test.rs b/src/terminal/scenario_test.rs index b8b42bf..47e58b4 100644 --- a/src/terminal/scenario_test.rs +++ b/src/terminal/scenario_test.rs @@ -248,3 +248,42 @@ fn scenario_osc8_link_spans_correct_cells() { // " for more." → no URL assert!(p.grid.cell(12, 0).url.is_none()); } + +// ── Scenario 7: TUI that styles with SGR colon subparameters ──────────────── +// +// Regression: a full-screen TUI (Claude Code's session picker) draws with +// `CSI 4:3 m` (curly underline) and turns it back off with `CSI 4:0 m`. +// When subparameters were dropped, `4:0` was read as a bare `4` and *enabled* +// underline instead — so every cell written after the TUI exited stayed +// underlined for the rest of the session. + +#[test] +fn scenario_tui_curly_underline_does_not_leak_after_exit() { + let mut p = term(40, 5); + + // TUI enters the alternate screen and draws an underlined entry + p.process(b"\x1b[?1049h"); + p.process(b"\x1b[4:3msession 1\x1b[4:0m"); + // ...and some truecolor text in colon form while it is up + p.process(b"\x1b[38:2:255:128:0m ok\x1b[0m"); + + // The underlined label is underlined; what follows `4:0` is not + assert!(p.grid.cell(0, 0).underline, "'s' of 'session 1'"); + assert!(!p.grid.cell(9, 0).underline, "space after 4:0"); + assert_eq!(p.grid.cell(10, 0).fg, Color::rgb(255, 128, 0)); + + // TUI exits back to the primary screen and the shell keeps printing + p.process(b"\x1b[?1049l"); + p.process(b"back in the shell"); + + assert!( + !p.grid.underline, + "pen state must not be left underlined after the TUI exits" + ); + for col in 0..17 { + assert!( + !p.grid.cell(col, 0).underline, + "col {col} of post-TUI output must not be underlined" + ); + } +}