Skip to content
Draft
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
4 changes: 4 additions & 0 deletions pkgs/io/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 1.2.0-wip

* Added an `AnsiRgbCode` class and a `rgb` utility function.

## 1.1.0-wip

* Add a `deepCopyLinks` argument to `copyPath` and `copyPathSync`.
Expand Down
22 changes: 22 additions & 0 deletions pkgs/io/example/example.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,28 @@ void main(List<String> args) {
_preview('Foreground', foregroundColors, forScript);
_preview('Background', backgroundColors, forScript);
_preview('Styles', styles, forScript);
_preview('Rgb', [rgb(255, 0, 0), rgb(0, 255, 0), rgb(0, 0, 255)], forScript);
_gradient('** Gradient Text Sample **', forScript);
}

void _gradient(String text, bool forScript) {
final length = text.length;
final buffer = StringBuffer();
for (var i = 0; i < length; i++) {
final ratio = i / (length - 1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There's a potential division by zero here if text.length is 1. length - 1 would be 0, and i / (length - 1) would result in NaN. This leads to a runtime error when rgb() is called with NaN values because they cannot be assigned to int. You should handle this edge case.

Suggested change
final ratio = i / (length - 1);
final ratio = length > 1 ? i / (length - 1) : 0.0;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or start with if (length == 0) return '';.

int red, green, blue;
if (ratio < .5) {
red = ((1 - (ratio * 2)) * 255).round();
green = (ratio * 2 * 255).round();
blue = 0;
} else {
red = 0;
green = ((1 - ((ratio - .5) * 2)) * 255).round();
blue = (((ratio - .5) * 2) * 255).round();
}
buffer.write(rgb(red, green, blue).wrap(text[i], forScript: forScript));
}
print(buffer.toString());
}

void _preview(String name, List<AnsiCode> values, bool forScript) {
Expand Down
76 changes: 72 additions & 4 deletions pkgs/io/lib/src/ansi_code.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,17 @@ class AnsiCodeType {
/// [Source](https://en.wikipedia.org/wiki/ANSI_escape_code#Colors)
class AnsiCode {
/// The numeric value associated with this code.
///
/// `-1` if this code is a composite code with multiple integer values.
/// See [codes].
final int code;

/// The numeric values associated with this code.
///
/// A composite code may have more than one integer value, in which case the
/// [code] property will be `-1`.
Iterable<int> get codes => [code];

/// The [AnsiCode] that resets this value, if one exists.
///
/// Otherwise, `null`.
Expand All @@ -76,10 +85,10 @@ class AnsiCode {
const AnsiCode._(this.name, this.type, this.code, this.reset);

/// Represents the value escaped for use in terminal output.
String get escape => '$_ansiEscapeLiteral[${code}m';
String get escape => '$_ansiEscapeLiteral[${codes.join(';')}m';

/// Represents the value as an unescaped literal suitable for scripts.
String get escapeForScript => '$_ansiEscapeForScript[${code}m';
String get escapeForScript => '$_ansiEscapeForScript[${codes.join(';')}m';

String _escapeValue({bool forScript = false}) =>
forScript ? escapeForScript : escape;
Expand All @@ -104,6 +113,34 @@ class AnsiCode {
String toString() => '$name ${type._name} ($code)';
}

/// An ANSI escape code for RGB colours.

@lrhn lrhn Feb 12, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this class, or it being public.

Could all AnsiCode objects have a codes list, and then AnsiCode.rgb just fills it in.

class AnsiCode {
  /// The numeric value associated with this code.
  ///
  /// `-1` if this code is a composite code with multiple integer values.
  /// See [codes].
  final int code;

  /// The numeric values associated with this code.
  ///
  /// A composite code may have more than one integer value, in which case the
  /// [code] property will be `-1`.
  Iterable<int> get codes => _codes ??= [code];

  AnsiCode._rgb(int red, int green, int blue, AnsiCodeType type) : 
    code = -1,
    _codes = List.unmodifiable([
        type == AnsiCodeType.background ? 48 : 38, 
        2,
        RangeError.checkValueInInterval(red, 0, 255, 'red'),
        RangeError.checkValueInInterval(green, 0, 255, 'green'),
        RangeError.checkValueInInterval(blue, 0, 255, 'blue'),
     ]);

Does codes even need to be public?
Did code? Having code now sometimes being -1 and invalid is a breaking change if anyone uses it, but why would anyone use it?
(Maybe we should make a breaking change to make the class more opaque, then it's easier to extend it.)

///
/// Represents a true colour (24-bit RGB) escape sequence that can be used for
/// both foreground and background colours.
///
/// Use [rgb] to create an instance of this class.
///
/// [See also](https://en.wikipedia.org/wiki/ANSI_escape_code#24-bit)
class AnsiRgbCode extends AnsiCode {

@lrhn lrhn Feb 12, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How well-supported is RGB colors in ANSI escapes these days?

I can see xterm and rxvt seem to supports all colors, so does the Windows terminal.
So probably fairly widely supported.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think it has wide enough support to be worth landing.

/// The red value (0-255).
final int red;

/// The green value (0-255).
final int green;

/// The blue value (0-255).
final int blue;

/// Creates an RGB [AnsiCode] for the given [type].
AnsiRgbCode._(this.red, this.green, this.blue, AnsiCodeType type)
: super._('rgb($red,$green,$blue)', type, -1, resetAll);

int get _prefix => type == AnsiCodeType.background ? 48 : 38;

@override
Iterable<int> get codes => [_prefix, 2, red, green, blue];
}

/// Returns a [String] formatted with [codes].
///
/// If [forScript] is `true`, the return value is an unescaped literal. The
Expand Down Expand Up @@ -150,14 +187,45 @@ String? wrapWith(String? value, Iterable<AnsiCode> codes,
break;
}
}
final codeParts = myCodes.expand((c) => c.codes).map((c) => c.toString());

final sortedCodes = myCodes.map((ac) => ac.code).toList()..sort();
final escapeValue = forScript ? _ansiEscapeForScript : _ansiEscapeLiteral;

return "$escapeValue[${sortedCodes.join(';')}m$value"
return "$escapeValue[${codeParts.join(';')}m$value"
'${resetAll._escapeValue(forScript: forScript)}';
}

/// Creates an [AnsiRgbCode] with the given RGB colour values.
///
/// The [red], [green], and [blue] parameters must be between 0 and 255.
///
/// By default, it creates a foreground colour. Pass [type] as
/// [AnsiCodeType.background] to create a background colour.
///
/// Throws an [ArgumentError] if any colour value is outside the 0-255 range or
/// if [type] is neither foreground nor background.
AnsiCode rgb(
int red,
int green,
int blue, {
AnsiCodeType type = AnsiCodeType.foreground,
}) {
if (red < 0 || red > 255) {
throw ArgumentError.value(red, 'red', 'Must be between 0 and 255.');

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lrhn - would RangeError be preferred here?

@lrhn lrhn Feb 12, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's relevant, so throw RangeError.range(red, 0, 255, 'red');.

And even use the helper function:

  RangeError.checkValueInInterval(red, 0, 255, 'red');
  RangeError.checkValueInInterval(green, 0, 255, 'green');
  RangeError.checkValueInInterval(blue, 0, 255, 'blue');

It's not important which Error subclass is used, the specialized ones are mainly there for easy reuse and consistent error messages. But that's reason enough to use it here too.

}
if (green < 0 || green > 255) {
throw ArgumentError.value(green, 'green', 'Must be between 0 and 255.');
}
if (blue < 0 || blue > 255) {
throw ArgumentError.value(blue, 'blue', 'Must be between 0 and 255.');
}
if (type != AnsiCodeType.foreground && type != AnsiCodeType.background) {
throw ArgumentError.value(
type, 'type', 'Must be either foreground or background.');
}
return AnsiRgbCode._(red, green, blue, type);
}

//
// Style values
//
Expand Down
24 changes: 22 additions & 2 deletions pkgs/io/test/ansi_code_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ void main() {
});
});

test('forScript variaents ignore `ansiOutputEnabled`', () {
test('forScript variants ignore `ansiOutputEnabled`', () {
const expected =
'$_ansiEscapeForScript[34m$sampleInput$_ansiEscapeForScript[0m';

Expand Down Expand Up @@ -112,6 +112,14 @@ void main() {
test(null, () {
expect(blue.wrap(null, forScript: forScript), isNull);
});

_test('rgb', () {
final rgbCode = rgb(128, 64, 32);
final expected =
'$escapeLiteral[38;2;128;64;32m$sampleInput$escapeLiteral[0m';

expect(rgbCode.wrap(sampleInput, forScript: forScript), expected);
});
});

group('wrapWith', () {
Expand Down Expand Up @@ -152,7 +160,7 @@ void main() {

_test('multi', () {
final expected =
'$escapeLiteral[1;4;34;107m$sampleInput$escapeLiteral[0m';
'$escapeLiteral[34;107;1;4m$sampleInput$escapeLiteral[0m';

expect(
wrapWith(sampleInput,
Expand All @@ -178,6 +186,18 @@ void main() {
forScript: forScript),
isNull);
});

_test('rgb', () {
final rgbCode = rgb(128, 64, 32);
final expected =
'$escapeLiteral[4;38;2;128;64;32m$sampleInput$escapeLiteral[0m';

expect(
wrapWith(sampleInput, [styleUnderlined, rgbCode],
forScript: forScript),
expected,
);
});
});
});
}
Expand Down
Loading