From 9652a41cf2f533f4656234f1a3e015763053a2e6 Mon Sep 17 00:00:00 2001 From: Heal75 <18739377+Heal75@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:59:55 +0200 Subject: [PATCH] fix(parser): support ':' sub-parameters in SGR The CSI parameter parser dropped ':' and merged the digits around it, so `ESC[4:0m` (underline off) was read as SGR 40 (black background) and `ESC[38:2::r:g:bm` was misparsed. Collect colon-separated sub-parameters (ITU T.416) and map them onto the semicolon forms the SGR handler already understands: `4:0` -> 24, `4:N` -> 4, `38/48:2[:cs]:r:g:b` and `38/48:5:n` -> their ';' equivalents. The underline colour (`58`) is accepted and ignored. --- lib/src/core/escape/parser.dart | 60 ++++++++++++++++++++++++++- test/src/core/escape/parser_test.dart | 31 ++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/lib/src/core/escape/parser.dart b/lib/src/core/escape/parser.dart index 0c7e74a9..727624ff 100644 --- a/lib/src/core/escape/parser.dart +++ b/lib/src/core/escape/parser.dart @@ -228,6 +228,12 @@ class EscapeParser { var param = 0; var hasParam = false; + // ':' separates sub-parameters (ITU T.416), e.g. + // `4:0` (underline off) or `38:2::r:g:b`. The ':' used to be dropped, which + // merged the digits (`4:0` became `40`). Collect each ';' group here. + var group = []; + var hasColon = false; + final groups = >[]; while (true) { // The sequence isn't completed, just ignore it. if (_queue.isEmpty) { @@ -236,7 +242,23 @@ class EscapeParser { final char = _queue.consume(); + if (char == Ascii.colon) { + hasColon = true; + group.add(param); + param = 0; + hasParam = true; + continue; + } + if (char == Ascii.semicolon) { + if (hasColon) { + group.add(param); + groups.add(group); + group = []; + param = 0; + hasParam = false; + continue; + } if (hasParam) { _csi.params.add(param); } @@ -257,7 +279,16 @@ class EscapeParser { } if (char >= Ascii.atSign && char <= Ascii.tilde) { - if (hasParam) { + if (hasColon) { + // Earlier ';' groups were kept apart; flatten them again. + if (hasParam) { + group.add(param); + } + groups.add(group); + for (final g in groups) { + _csi.params.addAll(_flattenSubParams(g, char)); + } + } else if (hasParam) { _csi.params.add(param); } @@ -267,6 +298,33 @@ class EscapeParser { } } + /// Turns one `;` group of colon-separated sub-parameters into the + /// equivalent semicolon-style parameters the handlers understand. + List _flattenSubParams(List g, int finalByte) { + if (g.length == 1) return g; + if (finalByte != 0x6d /* m */) return [g.first]; + + switch (g.first) { + case 4: // 4:0 none, 4:1..5 underline styles + return [g.length > 1 && g[1] == 0 ? 24 : 4]; + case 38: + case 48: + if (g.length > 2 && g[1] == 2) { + // 38:2:r:g:b or 38:2::r:g:b + final rgb = g.sublist(g.length - 3); + return [g.first, 2, ...rgb]; + } + if (g.length > 2 && g[1] == 5) { + return [g.first, 5, g[2]]; + } + return const []; + case 58: // underline colour: unsupported, swallow it + return const []; + default: + return [g.first]; + } + } + late final _csiHandlers = FastLookupTable<_CsiHandler>({ // 'a'.codeUnitAt(0): _csiHandleCursorHorizontalRelative, 'b'.codeUnitAt(0): _csiHandleRepeatPreviousCharacter, diff --git a/test/src/core/escape/parser_test.dart b/test/src/core/escape/parser_test.dart index 96fa5bb1..c9269b90 100644 --- a/test/src/core/escape/parser_test.dart +++ b/test/src/core/escape/parser_test.dart @@ -13,5 +13,36 @@ void main() { parser.write('\x1b[8;24;80t'); verify(parser.handler.resize(80, 24)); }); + + test('parses SGR colon sub-parameters', () { + final parser = EscapeParser(MockEscapeHandler()); + parser.write('\x1b[4m\x1b[4:0m'); + verifyInOrder([ + parser.handler.setCursorUnderline(), + parser.handler.unsetCursorUnderline(), + ]); + verifyNever(parser.handler.setBackgroundColor16(NamedColor.black)); + }); + + test('parses colon-separated underline styles as underline', () { + final parser = EscapeParser(MockEscapeHandler()); + parser.write('\x1b[4:3m'); + verify(parser.handler.setCursorUnderline()).called(1); + }); + + test('parses colon-separated RGB colours', () { + final parser = EscapeParser(MockEscapeHandler()); + parser.write('\x1b[38:2::1:2:3m\x1b[48:2:4:5:6m'); + verify(parser.handler.setForegroundColorRgb(1, 2, 3)); + verify(parser.handler.setBackgroundColorRgb(4, 5, 6)); + }); + + test('parses colon-separated 256 colours among other parameters', () { + final parser = EscapeParser(MockEscapeHandler()); + parser.write('\x1b[1;38:5:9;3m'); + verify(parser.handler.setCursorBold()); + verify(parser.handler.setForegroundColor256(9)); + verify(parser.handler.setCursorItalic()); + }); }); }