Add AnsiCode (dart-lang/io#10)
* Add AnsiCode class to represent terminal colors and styles
Fixes https://github.com/dart-lang/io/issues/9
diff --git a/pkgs/io/CHANGELOG.md b/pkgs/io/CHANGELOG.md
index 0035de1..b369f92 100644
--- a/pkgs/io/CHANGELOG.md
+++ b/pkgs/io/CHANGELOG.md
@@ -5,3 +5,4 @@
- `ExitCode`
- `ProcessManager` and `Spawn`
- `sharedStdIn` and `SharedStdIn`
+ - `ansi.dart` library with support for formatting terminal output
diff --git a/pkgs/io/example/ansi_code_example.dart b/pkgs/io/example/ansi_code_example.dart
new file mode 100644
index 0000000..9673b71
--- /dev/null
+++ b/pkgs/io/example/ansi_code_example.dart
@@ -0,0 +1,31 @@
+// Copyright 2017, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:math';
+
+import 'package:io/ansi.dart';
+
+/// Prints a sample of all of the `AnsiCode` values.
+void main() {
+ if (ansiOutputEnabled) {
+ print('`ansiOutputEnabled` is `false`.');
+ print("Don't expect pretty output.");
+ }
+ _preview('Foreground', foregroundColors);
+ _preview('Background', backgroundColors);
+ _preview('Styles', styles);
+}
+
+void _preview(String name, List<AnsiCode> values) {
+ print('');
+ final longest = values.map((ac) => ac.name.length).reduce(max);
+
+ print(wrapWith('** $name **', [styleBold, styleUnderlined]));
+ for (var code in values) {
+ final header =
+ "${code.name.padRight(longest)} ${code.code.toString().padLeft(3)}";
+
+ print("$header: ${code.wrap('Sample')}");
+ }
+}
diff --git a/pkgs/io/lib/ansi.dart b/pkgs/io/lib/ansi.dart
new file mode 100644
index 0000000..3a4231e
--- /dev/null
+++ b/pkgs/io/lib/ansi.dart
@@ -0,0 +1,5 @@
+// Copyright 2017, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+export 'src/ansi_code.dart';
diff --git a/pkgs/io/lib/src/ansi_code.dart b/pkgs/io/lib/src/ansi_code.dart
new file mode 100644
index 0000000..69303f8
--- /dev/null
+++ b/pkgs/io/lib/src/ansi_code.dart
@@ -0,0 +1,307 @@
+// Copyright 2017, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+// TODO(kevmoo) provide library documentation.
+library ansi_code;
+
+import 'dart:async';
+import 'dart:io' as io;
+
+/// Returns `true` if formatted ANSI output is enabled for [wrapWith] and
+/// [AnsiCode.wrap].
+///
+/// By default, returns `true` if both `stdout.supportsAnsiEscapes` and
+/// `stderr.supportsAnsiEscapes` from `dart:io` are `true`.
+///
+/// The default can be overridden by setting the [Zone] variable [AnsiCode] to
+/// either `true` or `false`.
+///
+/// [overrideAnsiOutput] is provided to make this easy.
+bool get ansiOutputEnabled =>
+ Zone.current[AnsiCode] as bool ??
+ (io.stdout.supportsAnsiEscapes && io.stderr.supportsAnsiEscapes);
+
+/// Allows overriding [ansiOutputEnabled] to [enableAnsiOutput] for the code run
+/// within [body].
+T overrideAnsiOutput<T>(bool enableAnsiOutput, T body()) =>
+ runZoned(body, zoneValues: <Object, Object>{AnsiCode: enableAnsiOutput});
+
+/// The type of code represented by [AnsiCode].
+class AnsiCodeType {
+ final String _name;
+
+ /// A foreground color.
+ static const AnsiCodeType foreground = const AnsiCodeType._('foreground');
+
+ /// A style.
+ static const AnsiCodeType style = const AnsiCodeType._('style');
+
+ /// A background color.
+ static const AnsiCodeType background = const AnsiCodeType._('background');
+
+ /// A reset value.
+ static const AnsiCodeType reset = const AnsiCodeType._('reset');
+
+ const AnsiCodeType._(this._name);
+
+ @override
+ String toString() => 'AnsiType.$_name';
+}
+
+/// Standard ANSI escape code for customizing terminal text output.
+///
+/// [Source](https://en.wikipedia.org/wiki/ANSI_escape_code#Colors)
+class AnsiCode {
+ /// The numeric value associated with this code.
+ final int code;
+
+ /// The [AnsiCode] that resets this value, if one exists.
+ ///
+ /// Otherwise, `null`.
+ final AnsiCode reset;
+
+ /// A description of this code.
+ final String name;
+
+ /// The type of code that is represented.
+ final AnsiCodeType type;
+
+ const AnsiCode._(this.name, this.type, this.code, this.reset);
+
+ /// Represents the value escaped for use in terminal output.
+ String get escape => "\x1B[${code}m";
+
+ /// Wraps [value] with the [escape] value for this code, followed by
+ /// [resetAll].
+ ///
+ /// Returns `value` unchanged if
+ /// * [value] is `null` or empty
+ /// * [ansiOutputEnabled] is `false`
+ /// * [type] is [AnsiCodeType.reset]
+ String wrap(String value) => (ansiOutputEnabled &&
+ type != AnsiCodeType.reset &&
+ value != null &&
+ value.isNotEmpty)
+ ? "$escape$value${reset.escape}"
+ : value;
+
+ @override
+ String toString() => "$name ${type._name} ($code)";
+}
+
+/// Returns a [String] formatted with [codes].
+///
+/// Returns `value` unchanged if
+/// * [value] is `null` or empty
+/// * [ansiOutputEnabled] is `false`
+/// * [codes] is empty.
+///
+/// Throws an [ArgumentError] if
+/// * [codes] contains more than one value of type
+/// [AnsiCodeType.background].
+/// * [codes] contains more than one value of type
+/// [AnsiCodeType.foreground].
+/// * [codes] contains more any value of type
+/// [AnsiCodeType.reset].
+String wrapWith(String value, Iterable<AnsiCode> codes) {
+ // Eliminate duplicates
+ final myCodes = codes.toSet();
+
+ if (myCodes.isEmpty || !ansiOutputEnabled || value == null || value.isEmpty) {
+ return value;
+ }
+
+ var foreground = 0, background = 0;
+ for (var code in myCodes) {
+ switch (code.type) {
+ case AnsiCodeType.foreground:
+ foreground++;
+ if (foreground > 1) {
+ throw new ArgumentError.value(codes, 'codes',
+ "Cannot contain more than one foreground color code.");
+ }
+ break;
+ case AnsiCodeType.background:
+ background++;
+ if (background > 1) {
+ throw new ArgumentError.value(codes, 'codes',
+ "Cannot contain more than one foreground color code.");
+ }
+ break;
+ case AnsiCodeType.reset:
+ throw new ArgumentError.value(
+ codes, 'codes', "Cannot contain reset codes.");
+ break;
+ }
+ }
+
+ final sortedCodes = myCodes.map((ac) => ac.code).toList()..sort();
+
+ return "\x1B[${sortedCodes.join(';')}m$value${resetAll.escape}";
+}
+
+//
+// Style values
+//
+
+const styleBold = const AnsiCode._('bold', AnsiCodeType.style, 1, resetBold);
+const styleDim = const AnsiCode._('dim', AnsiCodeType.style, 2, resetDim);
+const styleItalic =
+ const AnsiCode._('italic', AnsiCodeType.style, 3, resetItalic);
+const styleUnderlined =
+ const AnsiCode._('underlined', AnsiCodeType.style, 4, resetUnderlined);
+const styleBlink = const AnsiCode._('blink', AnsiCodeType.style, 5, resetBlink);
+const styleReverse =
+ const AnsiCode._('reverse', AnsiCodeType.style, 7, resetReverse);
+
+/// Not widely supported.
+const styleHidden =
+ const AnsiCode._('hidden', AnsiCodeType.style, 8, resetHidden);
+
+/// Not widely supported.
+const styleCrossedOut =
+ const AnsiCode._('crossed out', AnsiCodeType.style, 9, resetCrossedOut);
+
+//
+// Reset values
+//
+
+const resetAll = const AnsiCode._('all', AnsiCodeType.reset, 0, null);
+
+// NOTE: bold is weird. The reset code seems to be 22 sometimes – not 21
+// See https://gitlab.com/gnachman/iterm2/issues/3208
+const resetBold = const AnsiCode._('bold', AnsiCodeType.reset, 22, null);
+const resetDim = const AnsiCode._('dim', AnsiCodeType.reset, 22, null);
+const resetItalic = const AnsiCode._('italic', AnsiCodeType.reset, 23, null);
+const resetUnderlined =
+ const AnsiCode._('underlined', AnsiCodeType.reset, 24, null);
+const resetBlink = const AnsiCode._('blink', AnsiCodeType.reset, 25, null);
+const resetReverse = const AnsiCode._('reverse', AnsiCodeType.reset, 27, null);
+const resetHidden = const AnsiCode._('hidden', AnsiCodeType.reset, 28, null);
+const resetCrossedOut =
+ const AnsiCode._('crossed out', AnsiCodeType.reset, 29, null);
+
+//
+// Foreground values
+//
+
+const black = const AnsiCode._('black', AnsiCodeType.foreground, 30, resetAll);
+const red = const AnsiCode._('red', AnsiCodeType.foreground, 31, resetAll);
+const green = const AnsiCode._('green', AnsiCodeType.foreground, 32, resetAll);
+const yellow =
+ const AnsiCode._('yellow', AnsiCodeType.foreground, 33, resetAll);
+const blue = const AnsiCode._('blue', AnsiCodeType.foreground, 34, resetAll);
+const magenta =
+ const AnsiCode._('magenta', AnsiCodeType.foreground, 35, resetAll);
+const cyan = const AnsiCode._('cyan', AnsiCodeType.foreground, 36, resetAll);
+const lightGray =
+ const AnsiCode._('light gray', AnsiCodeType.foreground, 37, resetAll);
+const defaultForeground =
+ const AnsiCode._('default', AnsiCodeType.foreground, 39, resetAll);
+const darkGray =
+ const AnsiCode._('dark gray', AnsiCodeType.foreground, 90, resetAll);
+const lightRed =
+ const AnsiCode._('light red', AnsiCodeType.foreground, 91, resetAll);
+const lightGreen =
+ const AnsiCode._('light green', AnsiCodeType.foreground, 92, resetAll);
+const lightYellow =
+ const AnsiCode._('light yellow', AnsiCodeType.foreground, 93, resetAll);
+const lightBlue =
+ const AnsiCode._('light blue', AnsiCodeType.foreground, 94, resetAll);
+const lightMagenta =
+ const AnsiCode._('light magenta', AnsiCodeType.foreground, 95, resetAll);
+const lightCyan =
+ const AnsiCode._('light cyan', AnsiCodeType.foreground, 96, resetAll);
+const white = const AnsiCode._('white', AnsiCodeType.foreground, 97, resetAll);
+
+//
+// Background values
+//
+
+const backgroundBlack =
+ const AnsiCode._('black', AnsiCodeType.background, 40, resetAll);
+const backgroundRed =
+ const AnsiCode._('red', AnsiCodeType.background, 41, resetAll);
+const backgroundGreen =
+ const AnsiCode._('green', AnsiCodeType.background, 42, resetAll);
+const backgroundYellow =
+ const AnsiCode._('yellow', AnsiCodeType.background, 43, resetAll);
+const backgroundBlue =
+ const AnsiCode._('blue', AnsiCodeType.background, 44, resetAll);
+const backgroundMagenta =
+ const AnsiCode._('magenta', AnsiCodeType.background, 45, resetAll);
+const backgroundCyan =
+ const AnsiCode._('cyan', AnsiCodeType.background, 46, resetAll);
+const backgroundLightGray =
+ const AnsiCode._('light gray', AnsiCodeType.background, 47, resetAll);
+const backgroundDefault =
+ const AnsiCode._('default', AnsiCodeType.background, 49, resetAll);
+const backgroundDarkGray =
+ const AnsiCode._('dark gray', AnsiCodeType.background, 100, resetAll);
+const backgroundLightRed =
+ const AnsiCode._('light red', AnsiCodeType.background, 101, resetAll);
+const backgroundLightGreen =
+ const AnsiCode._('light green', AnsiCodeType.background, 102, resetAll);
+const backgroundLightYellow =
+ const AnsiCode._('light yellow', AnsiCodeType.background, 103, resetAll);
+const backgroundLightBlue =
+ const AnsiCode._('light blue', AnsiCodeType.background, 104, resetAll);
+const backgroundLightMagenta =
+ const AnsiCode._('light magenta', AnsiCodeType.background, 105, resetAll);
+const backgroundLightCyan =
+ const AnsiCode._('light cyan', AnsiCodeType.background, 106, resetAll);
+const backgroundWhite =
+ const AnsiCode._('white', AnsiCodeType.background, 107, resetAll);
+
+/// All of the [AnsiCode] values that represent [AnsiCodeType.style].
+const List<AnsiCode> styles = const [
+ styleBold,
+ styleDim,
+ styleItalic,
+ styleUnderlined,
+ styleBlink,
+ styleReverse,
+ styleHidden,
+ styleCrossedOut
+];
+
+const List<AnsiCode> foregroundColors = const [
+ black,
+ red,
+ green,
+ yellow,
+ blue,
+ magenta,
+ cyan,
+ lightGray,
+ defaultForeground,
+ darkGray,
+ lightRed,
+ lightGreen,
+ lightYellow,
+ lightBlue,
+ lightMagenta,
+ lightCyan,
+ white
+];
+
+const List<AnsiCode> backgroundColors = const [
+ backgroundBlack,
+ backgroundRed,
+ backgroundGreen,
+ backgroundYellow,
+ backgroundBlue,
+ backgroundMagenta,
+ backgroundCyan,
+ backgroundLightGray,
+ backgroundDefault,
+ backgroundDarkGray,
+ backgroundLightRed,
+ backgroundLightGreen,
+ backgroundLightYellow,
+ backgroundLightBlue,
+ backgroundLightMagenta,
+ backgroundLightCyan,
+ backgroundWhite
+];
diff --git a/pkgs/io/test/ansi_code_test.dart b/pkgs/io/test/ansi_code_test.dart
new file mode 100644
index 0000000..89b2512
--- /dev/null
+++ b/pkgs/io/test/ansi_code_test.dart
@@ -0,0 +1,147 @@
+// Copyright 2017, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+@TestOn('vm')
+import 'dart:io';
+import 'package:io/ansi.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('ansiOutputEnabled', () {
+ test("default value matches dart:io", () {
+ expect(ansiOutputEnabled,
+ stdout.supportsAnsiEscapes && stderr.supportsAnsiEscapes);
+ });
+
+ test("override true", () {
+ overrideAnsiOutput(true, () {
+ expect(ansiOutputEnabled, isTrue);
+ });
+ });
+
+ test("override false", () {
+ overrideAnsiOutput(false, () {
+ expect(ansiOutputEnabled, isFalse);
+ });
+ });
+ });
+
+ test('foreground and background colors match', () {
+ expect(foregroundColors, hasLength(backgroundColors.length));
+
+ for (var i = 0; i < foregroundColors.length; i++) {
+ final foreground = foregroundColors[i];
+ expect(foreground.type, AnsiCodeType.foreground);
+ expect(foreground.name.toLowerCase(), foreground.name,
+ reason: "All names should be lower case");
+ final background = backgroundColors[i];
+ expect(background.type, AnsiCodeType.background);
+ expect(background.name.toLowerCase(), background.name,
+ reason: "All names should be lower case");
+
+ expect(foreground.name, background.name);
+
+ // The last base-10 digit also matches – good to sanity check
+ expect(foreground.code % 10, background.code % 10);
+ }
+ });
+
+ test('all styles are styles', () {
+ for (var style in styles) {
+ expect(style.type, AnsiCodeType.style);
+ expect(style.name.toLowerCase(), style.name,
+ reason: "All names should be lower case");
+ if (style == styleBold) {
+ expect(style.reset, resetBold);
+ } else {
+ expect(style.reset.code, equals(style.code + 20));
+ }
+ expect(style.name, equals(style.reset.name));
+ }
+ });
+
+ final sampleInput = 'sample input';
+
+ group('wrap', () {
+ _test("color", () {
+ final expected = '\x1B[34m$sampleInput\x1B[0m';
+
+ expect(blue.wrap(sampleInput), expected);
+ });
+
+ _test("style", () {
+ final expected = '\x1B[1m$sampleInput\x1B[22m';
+
+ expect(styleBold.wrap(sampleInput), expected);
+ });
+
+ _test("style", () {
+ final expected = '\x1B[34m$sampleInput\x1B[0m';
+
+ expect(blue.wrap(sampleInput), expected);
+ });
+
+ test("empty", () {
+ expect(blue.wrap(''), '');
+ });
+
+ test(null, () {
+ expect(blue.wrap(null), isNull);
+ });
+ });
+
+ group('wrapWith', () {
+ _test("foreground", () {
+ final expected = '\x1B[34m$sampleInput\x1B[0m';
+
+ expect(wrapWith(sampleInput, [blue]), expected);
+ });
+
+ _test("background", () {
+ final expected = '\x1B[44m$sampleInput\x1B[0m';
+
+ expect(wrapWith(sampleInput, [backgroundBlue]), expected);
+ });
+
+ _test("style", () {
+ final expected = '\x1B[1m$sampleInput\x1B[0m';
+
+ expect(wrapWith(sampleInput, [styleBold]), expected);
+ });
+
+ _test("2 styles", () {
+ final expected = '\x1B[1;3m$sampleInput\x1B[0m';
+
+ expect(wrapWith(sampleInput, [styleBold, styleItalic]), expected);
+ });
+
+ _test("2 foregrounds", () {
+ expect(() => wrapWith(sampleInput, [blue, white]), throwsArgumentError);
+ });
+
+ _test("multi", () {
+ final expected = '\x1B[1;4;34;107m$sampleInput\x1B[0m';
+
+ expect(
+ wrapWith(
+ sampleInput, [blue, backgroundWhite, styleBold, styleUnderlined]),
+ expected);
+ });
+
+ test('no codes', () {
+ expect(wrapWith(sampleInput, []), sampleInput);
+ });
+
+ _test("empty", () {
+ expect(wrapWith('', [blue, backgroundWhite, styleBold]), '');
+ });
+
+ _test(null, () {
+ expect(wrapWith(null, [blue, backgroundWhite, styleBold]), isNull);
+ });
+ });
+}
+
+void _test<T>(String name, T body()) =>
+ test(name, () => overrideAnsiOutput<T>(true, body));