Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 72 additions & 53 deletions lib/core/theme/parser/color_parser.dart
Original file line number Diff line number Diff line change
@@ -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');
}
Expand All @@ -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');
}
Expand All @@ -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<int>.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<int> 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);
39 changes: 39 additions & 0 deletions test/core/theme/parser/color_parser_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
});
});
}
Loading