diff --git a/lib/core/theme/parser/color_parser.dart b/lib/core/theme/parser/color_parser.dart index 8627bc39..2caead4a 100644 --- a/lib/core/theme/parser/color_parser.dart +++ b/lib/core/theme/parser/color_parser.dart @@ -1,34 +1,49 @@ import 'dart:ui'; /// Parses VS Code color strings into Flutter [Color]. +/// +/// Hex digits are read straight from the code units (no substring / `int.parse` +/// per channel), so loading a theme with thousands of rules allocates nothing +/// per color but the result. Color parseVsCodeColor(String input) { - var s = input.trim(); + final s = input.trim(); if (s.isEmpty) { throw const FormatException('Empty color string'); } - if (s.startsWith('#')) { - s = s.substring(1); - } - if (s.length == 3) { - final r = s[0]; - final g = s[1]; - final b = s[2]; - s = '$r$r$g$g$b$b'; - return Color(int.parse('FF$s', radix: 16)); - } - if (s.length == 4) { - final r = s[0]; - final g = s[1]; - final b = s[2]; - final a = s[3]; - s = '$r$r$g$g$b$b$a$a'; - return _fromRgbaHex(s); - } - if (s.length == 6) { - return Color(int.parse('FF$s', radix: 16)); - } - if (s.length == 8) { - return _fromRgbaHex(s); + final from = s.codeUnitAt(0) == 0x23 ? 1 : 0; // '#' + try { + switch (s.length - from) { + case 3: // RGB + return Color.fromARGB( + 0xFF, + _nibble(s, from) * 17, + _nibble(s, from + 1) * 17, + _nibble(s, from + 2) * 17, + ); + case 4: // RGBA + return Color.fromARGB( + _nibble(s, from + 3) * 17, + _nibble(s, from) * 17, + _nibble(s, from + 1) * 17, + _nibble(s, from + 2) * 17, + ); + case 6: // RRGGBB + return Color.fromARGB( + 0xFF, + _byte(s, from), + _byte(s, from + 2), + _byte(s, from + 4), + ); + case 8: // RRGGBBAA + return Color.fromARGB( + _byte(s, from + 6), + _byte(s, from), + _byte(s, from + 2), + _byte(s, from + 4), + ); + } + } on FormatException { + throw FormatException('Unsupported color format: $input'); } throw FormatException('Unsupported color format: $input'); } @@ -51,11 +66,12 @@ Color parseQueryaThemeColor(String raw) { if (s.length == 8) { try { - final aa = int.parse(s.substring(0, 2), radix: 16); - final rr = int.parse(s.substring(2, 4), radix: 16); - final gg = int.parse(s.substring(4, 6), radix: 16); - final bb = int.parse(s.substring(6, 8), radix: 16); - return Color.fromARGB(aa, rr, gg, bb); + return Color.fromARGB( + _byte(s, 0), + _byte(s, 2), + _byte(s, 4), + _byte(s, 6), + ); } on FormatException { throw FormatException('Invalid Querya theme color: $raw'); } @@ -70,30 +86,33 @@ Color parseQueryaThemeColor(String raw) { /// Encodes a [Color] as a VS Code hex string (`#RRGGBB` or `#RRGGBBAA`). String formatVsCodeColor(Color color) { - String channel(double component) => (component * 255.0) - .round() - .clamp(0, 255) - .toRadixString(16) - .padLeft(2, '0'); - final rr = channel(color.r); - final gg = channel(color.g); - final bb = channel(color.b); - if (color.a < 1.0) { - final aa = channel(color.a); - return '#$rr$gg$bb$aa'; - } - return '#$rr$gg$bb'; + final withAlpha = color.a < 1.0; + final units = List.filled(withAlpha ? 9 : 7, 0x23); // '#' + _writeHexByte(units, 1, _channel(color.r)); + _writeHexByte(units, 3, _channel(color.g)); + _writeHexByte(units, 5, _channel(color.b)); + if (withAlpha) _writeHexByte(units, 7, _channel(color.a)); + return String.fromCharCodes(units); +} + +int _channel(double component) => + (component * 255.0).round().clamp(0, 255); + +const String _hexChars = '0123456789abcdef'; + +void _writeHexByte(List out, int at, int value) { + out[at] = _hexChars.codeUnitAt(value >> 4); + out[at + 1] = _hexChars.codeUnitAt(value & 0xF); } -Color _fromRgbaHex(String eight) { - final rr = eight.substring(0, 2); - final gg = eight.substring(2, 4); - final bb = eight.substring(4, 6); - final aa = eight.substring(6, 8); - return Color.fromARGB( - int.parse(aa, radix: 16), - int.parse(rr, radix: 16), - int.parse(gg, radix: 16), - int.parse(bb, radix: 16), - ); +/// The hex digit at [at] in [s], or a [FormatException]. +int _nibble(String s, int at) { + final c = s.codeUnitAt(at); + if (c >= 0x30 && c <= 0x39) return c - 0x30; // 0-9 + if (c >= 0x61 && c <= 0x66) return c - 0x61 + 10; // a-f + if (c >= 0x41 && c <= 0x46) return c - 0x41 + 10; // A-F + throw FormatException('Invalid hex digit', s, at); } + +/// Two hex digits at [at] as one byte. +int _byte(String s, int at) => (_nibble(s, at) << 4) | _nibble(s, at + 1); diff --git a/test/core/theme/parser/color_parser_test.dart b/test/core/theme/parser/color_parser_test.dart index d7b256f8..23a0ae1e 100644 --- a/test/core/theme/parser/color_parser_test.dart +++ b/test/core/theme/parser/color_parser_test.dart @@ -100,4 +100,43 @@ void main() { ); }); }); + + group('hex scanner (#1541)', () { + test('4-digit shorthand is RGBA, each nibble doubled', () { + expect(parseVsCodeColor('#1234'), const Color(0x44112233)); + expect(parseVsCodeColor('f08c'), const Color(0xCCFF0088)); + }); + + test('8-digit VS Code value is RRGGBBAA, Querya value is AARRGGBB', () { + expect(parseVsCodeColor('#11223344'), const Color(0x44112233)); + expect(parseQueryaThemeColor('#44112233'), const Color(0x44112233)); + }); + + test('upper and lower case digits parse the same', () { + expect(parseVsCodeColor('#AbCdEf'), parseVsCodeColor('#abcdef')); + expect(parseVsCodeColor('#AbCdEf'), const Color(0xFFABCDEF)); + }); + + test('signs, spaces and non-hex characters are rejected', () { + for (final bad in ['#+12345', '#-12345', '#12 456', '#12345g', '#zzz']) { + expect(() => parseVsCodeColor(bad), throwsFormatException, + reason: bad); + } + expect(() => parseQueryaThemeColor('+1234567'), throwsFormatException); + }); + + test('formatVsCodeColor writes lowercase hex and alpha only when needed', + () { + expect(formatVsCodeColor(const Color(0xFF000000)), '#000000'); + expect(formatVsCodeColor(const Color(0xFF0A0B0C)), '#0a0b0c'); + expect(formatVsCodeColor(const Color(0x80112233)), '#11223380'); + }); + + test('every channel value round-trips through format and parse', () { + for (var v = 0; v < 256; v++) { + final c = Color.fromARGB(0xFF, v, 255 - v, v ^ 0x5A); + expect(parseVsCodeColor(formatVsCodeColor(c)), c, reason: 'v=$v'); + } + }); + }); }