ansi_escape_codes 4.0.1 copy "ansi_escape_codes: ^4.0.1" to clipboard
ansi_escape_codes: ^4.0.1 copied to clipboard

A toolkit for working with ANSI escape codes and analyzing strings containing them.

Dart CI Pub Publisher Pub Version GitHub License

A toolkit for working with ANSI escape codes and analyzing strings containing them.

ANSI escape sequences are a standard for in-band signaling to control cursor location, color, font styling, and other options on video text terminals and terminal emulators. Certain sequences of bytes, most starting with an ASCII escape character and a bracket character, are embedded into text. The terminal interprets these sequences as commands, rather than text to display verbatim. Wikipedia

Both halves of that, in one import:

import 'package:ansi_escape_codes/ansi_escape_codes.dart';

// Writing: by a style, or by constants that cost nothing at run time.
print('${Styles.red.bold('ERROR')} the roof is on fire');
print('${fgCyan}the same in cyan$reset');

// Reading: what a string says, how long it is without the codes, and what
// the style is at any point of it.
const line = '${fgRed}ERROR$reset: the roof is on fire';
final parser = Parser(line);

print(parser.removeAll()); // ERROR: the roof is on fire
print(parser.length); // 26
print(parser.stateAt(3).foregroundColor?.id); // fgRed
print(parser.showControlFunctions()); // [fgRed]ERROR[reset]: the roof is on fire

Features #

  • coloring: you can use ready-to-use values to create constant strings and maximize performance, or choose the power of styles.
  • cursor and terminal control
  • reading strings that carry escape codes: what they say, how long they are without the codes, what style is in force at any point
  • hyperlinks that survive the cut: a link a slice or a line break falls inside of is opened again and goes on being one link
  • a default style for everything the application prints

Table of contents #

Quick start #

How do I color text? #

You can use different levels of coloring.

Close to ANSI standard

If you need a level that is as close as possible to ANSI, you can use ready-made constants that comply with the standard.

import 'package:ansi_escape_codes/ansi.dart';

void main() {
  const text = '$CSI$FG_GREEN$SGR Green text $CSI$FG_DEFAULT$SGR$LF'
    '$CSI$FOREGROUND;$COLOR_256;$RGB_520$SGR Orange text $CSI$RESET$SGR';

  print(text);
}

Most likely, this option will only be of interest to specialists in the standard.

Every one of them is listed in the reference, and they live in lib/src/ansi.

Maximum performance

A convenient and highly efficient option is to use ready-to-use values that hide the complexity of ANSI:

import 'package:ansi_escape_codes/ansi_escape_codes.dart';

void main() {
  const text = '$fgGreen Green text $resetFg'
    '$bgYellow Yellow background $resetBg'
    '$bold Bold text $resetBoldAndDim'
    '$italic Italic text $resetItalic'
    '$underline Underline text $resetUnderline'
    '$reset';

  print(text);
}

Its main feature is that it allows you to create constant strings that are ready to use.

fgGreen is an ANSI escape sequence that sets the text color to green. bgYellow sets the background color to yellow. And so on.

resetFg resets the text color to the default color set in your terminal. resetBg resets the background color to the default color. And so on.

Note

Please note the following example:

print('$fgGreen Green text $fgYellow Yellow text $resetFg Default text');

After resetFg, the text color will not revert to fgGreen, but will return to the standard terminal text color!

If you need the ability to roll back to the previous color, use styles or StackedPrinter.

bold and dim are the two ends of one property — the intensity — and ANSI puts it back to normal with a single code: resetBoldAndDim. Both can be on at once, and that one code takes off whichever of them are.

reset returns all settings to default.

The reference names them beside the codes they write, and they live in lib/src/ready_to_use.

The power of styles

import 'package:ansi_escape_codes/style.dart';

void main() {
  final defaultStyle = Styles.gray12;
  final greenStyle = Styles.green.bold;
  final highlighedStyle = Styles.red.bgYellow.underline;

  print(
    defaultStyle(
      'Normal text'
      ' ${greenStyle('Green ${highlighedStyle('Highlighted text')} text')}'
      ' Normal text',
    ),
  );
}

First, you can assemble your own style from any pieces. Every style that carries one thing is a constant on Styles — a property, or a colour of the table — and a chain goes on from whichever of them comes first:

final mine = Styles.rgb050.bgRgb010.bold.italic.underline;
final warning = Styles.red.bold;

Second, styles can be nested: after completing the action of a nested style, the style will return to the parent style.

The names this package brings #

Each entry point brings a different part of the package:

Import Names What it brings
ansi_escape_codes.dart ~1000 all the string work: the ready-to-use strings (fgRed, cursorUp), the styles, the parser, the state, the control function tables and the String extensions
ansi.dart ~500 the bytes the standard names: CSI, CUU, BOLD, RESERVED_5F. The only one that is not part of the first — the ready-to-use strings are built from these, and neither brings the other
style.dart 82 the styles, the state and the parser with its control function tables, without the tables of ready-to-use strings
extensions.dart 8 the String extensions, with the two enums their signatures name and the exception the two insertions throw
utils.dart 2 tabs and currentCursorPos alone

style.dart and extensions.dart are parts of the first, and are there for the times a smaller namespace is worth an import of its own — a program that only reads escape codes has no use for the 900 constants that write them. The styles and the parser live in the same library, so style.dart brings both: writing a style and reading one back are the two halves of the same surface.

utils.dart stands apart for a different reason. Its two functions talk to a terminal in person, through dart:io, and nothing else here does: the rest is string work that runs wherever Dart does, the web and WebAssembly included. Bringing them in through the umbrella would have made the whole package native-only for the sake of two names, so they are asked for by name:

import 'package:ansi_escape_codes/ansi_escape_codes.dart';
import 'package:ansi_escape_codes/utils.dart';

One import is usually enough:

import 'package:ansi_escape_codes/ansi_escape_codes.dart';

print('${bold}by the string$reset');
print(Styles.bold('by the style'));

The string and the style are told apart by where they are: bold is a String of escape codes, Styles.bold is the style. Of the styles nothing is written in lowercase, so the two never collide.

The package also exports names Flutter uses for its own: Text, State, Stack, Colors and Color. Nothing breaks until one of them is written, and then the compiler asks which was meant — Flutter is imported explicitly, and between two explicit imports the question stays open for you to answer. In a Flutter app, hide the side you are not calling by that name:

import 'package:ansi_escape_codes/ansi_escape_codes.dart'
    hide Color, Colors, Stack, State, Text;

The same hiding is wanted for style.dart: the parser is what defines those five names, and both of these imports bring it. Only ansi.dart, extensions.dart and utils.dart are free of them: ansi.dart brings constants, utils.dart its two functions, and extensions.dart the extensions with the two enums their signatures name — ControlCodeStyle and ControlFunctionsC0.

Nothing here shadows a name of dart:core's, and that is a deliberate change: what the parser hands out was Match until 4.0.0, which shadowed dart:core.Match silently — an explicit import outranks the implicit one, so the compiler never asked, and ordinary code with a regular expression failed with errors that named no package. It is Piece now, and Matches is Pieces.

What is exported in lowercase is the ready-to-use strings — fgRed, cursorUp, bold — and tabs. The styles are not among them: they are constants on Styles, so the name most likely to meet one of your own is tabs, and hide or a prefix settles that the same way.

Writing #

The constants and the styles are the two ways of dressing a string; the printers are for dressing everything a program prints, whether it asked to be dressed or not.

Constants, and the strings built from them #

Strings containing ANSI escape codes can be constants:

const text = '$fgGreen Green text $resetFg'
    '$bgYellow Yellow background $resetBg'
    '$bold Bold text $resetBoldAndDim'
    '$italic Italic text $resetItalic'
    '$underline Underline text $resetUnderline';
print(text);

For complex cases there are functions:

final nonConstantText = '${fgRgb(255, 128, 0)} Orange text $resetFg';
print(nonConstantText);

But even in these cases it is possible to switch to constants:

const constantText = '${fgRgbOpen}255;128;0$fgRgbClose Orange text $resetFg';
print(constantText);

Of course, nothing prevents you from using the escape codes themselves directly. But even in this case you can use predefined constants to make the text more readable.

All of the following examples are equivalent:

import 'package:ansi_escape_codes/ansi.dart';
import 'package:ansi_escape_codes/ansi_escape_codes.dart';

print('\x1B[38;2;255;128;0m Orange text \x1B[0m');
print('$ESC[38;2;255;128;0m Orange text $ESC[0m');
print('${CSI}38;2;255;128;0$SGR Orange text ${CSI}0$SGR');
print('$CSI$FOREGROUND;$COLOR_RGB;255;128;0$SGR Orange text $CSI$RESET$SGR');
print('${fgRgbOpen}255;128;0$fgRgbClose Orange text $reset');
print('${fgRgb(255, 128, 0)} Orange text $reset'); // Not constant!

Control codes are deliberately named in SCREAMING_SNAKE_CASE as opposed to the common Dart camelCase. First, this is how they are named in the Standard. Second, in this form they will not prevent you from naming your own variables. Thirdly, and most importantly, most users do not need to use them directly.

Every one of them is listed in the reference — the C0 and C1 sets, the final bytes of the control sequences, the independent functions, all the SGR parameters, the 256-color table and the 24-bit colors — with what each does and the ready-to-use name beside it. What follows here is the table that is reached for while writing rather than searched.

Ready-to-use functions and constants #

Ready-to-use functions and constants replace the use of control functions with the style used in Dart.

Goal Using Description
Cursor up template: ${cursorUpOpen}$n$cursorUpClose
function: cursorUpN(int n)
default constant: cursorUp
Moves the cursor up n (default 1) lines.
Cursor down template: ${cursorDownOpen}$n$cursorDownClose
function: cursorDownN(int n)
default constant: cursorDown
Moves the cursor down n (default 1) lines.
Cursor forward template: ${cursorRightOpen}$n$cursorRightClose
function: cursorRightN(int n)
default constant: cursorRight
Moves the cursor right n (default 1) characters.
Cursor back template: ${cursorLeftOpen}$n$cursorLeftClose
function: cursorLeftN(int n)
default constant: cursorLeft
Moves the cursor left n (default 1) characters.
Cursor next line template: ${cursorNextLineOpen}$n$cursorNextLineClose
function: cursorNextLineN(int n)
default constant: cursorNextLine
Moves cursor to beginning of the line n (default 1) lines down.
Cursor prev line template: ${cursorPrevLineOpen}$n$cursorPrevLineClose
function: cursorPrevLineN(int n)
default constant: cursorPrevLine
Moves cursor to beginning of the line n (default 1) lines up.
Cursor horizontal pos template: ${cursorHPosOpen}$n$cursorHPosClose
function: cursorHPosTo(int n)
default constant: cursorHPosToBegin
Moves the cursor to column n (default 1).
Cursor pos template: ${cursorPosOpen}$row;$col$cursorPosClose
function: cursorPosTo(int row, int col)
default constant: cursorPosToTopLeft
Moves the cursor to row and col.
Cursor horizontal and vertical pos template: ${cursorHVPosOpen}$row;$col$cursorHVPosClose
function: cursorHVPosTo(int row, int col)
default constant: cursorHVPosToTopLeft
Same as cursorPosTo, just with some differences.
Erase in page template: ${eraseInPageOpen}$s$eraseInPageClose
function:
default constants: erasePage, eraseInPageToBegin, eraseInPageToEnd
Erases part of the page: s=0 (or missing) - to end, s=1 - to beginning, s=2 - entire page.
Erase in line template: ${eraseInLineOpen}$s$eraseInLineClose
function:
default constants: eraseLine, eraseInLineToBegin, eraseInLineToEnd
Erases part of the line: s=0 (or missing) - to end, s=1 - to beginning, s=2 - entire line.
Scroll up template: ${scrollUpOpen}$n$scrollUpClose
function: scrollUpN(int n)
default constant: scrollUp
Scroll page up by n (default 1) lines. New lines are added at the bottom.
Scroll down template: ${scrollDownOpen}$n$scrollDownClose
function: scrollDownN(int n)
default constant: scrollDown
Scroll page down by n (default 1) lines. New lines are added at the top.
Hide cursor constant: hideCursor Hides the cursor.
Show cursor constant: showCursor Shows the cursor.
Save cursor constant: saveCursor Saves the cursor position, encoding shift state and formatting attributes.
Restore cursor constant: restoreCursor Restores the cursor position, encoding shift state and formatting attributes from the previous saveCursor if any, otherwise resets these all to their defaults.
Alternate screen constant: useAlternateScreen Switches to the screen a full-screen program draws on: the cursor is saved, the alternate screen is cleared, and the screen the program was started from is left untouched.
Main screen constant: useMainScreen Switches back to the screen the program was started from, scrollback and all, with the cursor where useAlternateScreen left it.

All of the following examples are equivalent:

print('\x1B[4A');
print('${CSI}4$CUU');
print('${cursorUpOpen}4$cursorUpClose');
print(cursorUpN(4)); // Not constant!

Styles #

Styles holds every style that carries one thing, and there are 802 of them: the 34 properties — Styles.bold, Styles.italic — and the 256-colour table three times over, Styles.red for the colour of the text, Styles.bgRed for the colour behind it, Styles.underlineRed for the colour of the underline. Being constants, a style can be held in one: const error = Styles.red.

The state model also keeps the standard font selection, italic/fraktur shape, five underline variants, proportional spacing and five ideogram renditions. They compose like the existing properties:

final heading = Styles.alternativeFont1.fraktur.curlyUnderline;

Reverse operations preserve an ordinary CSI ... m function even when the package cannot name its effect. Once such a function is active, later SGR operations are replayed in order through cuts, insertions and printer line boundaries until SGR 0 clears the opaque rendition. Private CSI and CSI with intermediate bytes are copied where they occur but are not treated as replayable SGR state.

A chain builds on them — Styles.red.bold.bgYellow — and the colors the table does not name are passed as values:

import 'package:ansi_escape_codes/ansi_escape_codes.dart';

final mine = Styles.underline
    .foreground(Color256.rgb(5, 2, 0)) // the 6x6x6 cube
    .background(ColorRgb(0x33, 0x66, 0x99)) // 24-bit
    .underlineColor(Color256.gray(12)) // one of the 24 grays
    .underline;

print(mine('text').ansiShowEscapeSequences());
// [CSI 0 SGR][CSI 38;5;208 SGR][CSI 48;2;51;102;153 SGR][CSI 58;5;244 SGR]
// [CSI 4 SGR]text[CSI 0 SGR]

Every entry of the table has a name of its own as well — Color256.rgb520, Color256.gray12, Color256.red — and Color16 holds the sixteen the terminal names itself, which are the ones the short CSI 31 form writes. The underline is the one that takes no Color16: the standard gives it no sixteen-color form, so underlineColor asks for an ExtendedColor.

A style hands the colors back under the name of the slot they are held in, whichever way the style was built:

print(mine.foregroundColor?.id); // fg256Rgb520
print(mine.backgroundColor?.id); // bgRgb(51,102,153)
print(mine.underlineColorValue?.id); // underline256Gray12

// The same from a style written as a constant, where nothing could be
// called to set the target.
print(const Style(foreground: Color16.red).foregroundColor?.id); // fgRed

That is what ColorTarget is for. A color standing on its own has no slot to be named by, and says so with a ?; putting it in one is what gives it a name:

print(Color256.rgb(5, 2, 0).id); // ?256Rgb520
print(Style(background: Color256.rgb(5, 2, 0)).backgroundColor?.id);
// bg256Rgb520

Calling a style wraps a string. Where the two halves are wanted apart — a buffer written to in pieces, a style that outlives one call — they are open and close:

final warning = Styles.red.bold;

print(warning.open.ansiShowEscapeSequences()); // [CSI 38;5;1 SGR][CSI 1 SGR]
print(warning.close.ansiShowEscapeSequences()); // [CSI 0 SGR]
print(warning('text').ansiShowEscapeSequences());
// [CSI 0 SGR][CSI 38;5;1 SGR][CSI 1 SGR]text[CSI 0 SGR]

open does not begin with a reset: it writes the difference from the terminal's own colors, and assumes the terminal is in them. The call form writes the reset first. NoStyle answers with an empty string to both, so code holding a style needs no test for it.

Printer #

Escape codes do not allow you to set default values for your text. The foreground and background colors depend on the implementation of the terminal you are using. And so if you want to use some other values, you cannot use resetFg (CSI FOREGROUND_DEFAULT SGR) and resetBg (CSI BACKGROUND_DEFAULT SGR). Each time you will have to substitute your own values instead:

const text =
    '$bg256Rgb113$fg256Rgb442 Default text '
    '$bgWhite$fgBlack Highlighted text '
    '$bg256Rgb113$fg256Rgb442 Default text again $reset';
print(text);

You can move the color setting to constants and use them everywhere:

const defaultStyle = '$bg256Rgb113$fg256Rgb442';
const text = '$defaultStyle Default text '
    '$bgWhite$fgBlack Highlighted text '
    '$defaultStyle Default text again $reset';
print(text);

Or you can use Printer:

const text = ' Default text '
    '$bgWhite$fgBlack Highlighted text $reset'
    ' Default text again';
final printer = Printer(
  defaultStyle: const Style(
    background: Color256.rgb113,
    foreground: Color256.rgb442,
  ),
);
printer.print(text);

The printer itself will substitute the correct values where the state returns to default. The texts will remain clean, and you can change the default values or remove them altogether at any time.

A printer also treats ESC 7 / ESC 8 as one session-wide cursor save slot. The saved rendition, hyperlink and opaque SGR state survive line boundaries (and write boundaries for a sink printer); another save replaces the slot, and a restore does not consume it. Before the first save, restore returns to the terminal defaults rather than to the state carried from the previous line.

Additionally, Dart allows you to use zones to hide the use of the printer under the hood:

void main() {
  runZonedPrinter(
    defaultStyle: const Style(
    background: Color256.rgb113,
    foreground: Color256.rgb442,
    ),
    () {
      // … Your application code …

      const text = ' Default text '
          '$bgWhite$fgBlack Highlighted text $reset'
          ' Default text again';

      print(text); // Use the usual print
    },
  );
}

All calls to the print function will be intercepted and modified to use the values you need.

If you need the codes for debugging Flutter apps, you'll notice that when debugging iOS apps, the console will receive messages with escaped escape codes in them. This is a known issue and is currently (02.2025) unresolved: https://github.com/flutter/flutter/issues/20663. There is no way around this issue. But there are two ways to minimize it.

The first way is to use the log method from 'dart:developer'. The log outputs the escape codes on iOS correctly:

import 'dart:developer';

…

runZonedPrinter(
  defaultStyle: const Style(
    background: Color16.green,
    foreground: Color16.yellow,
  ),
  output: log,
  () {
    const text = ' Default text '
        '$bgWhite$fgBlack Highlighted text $resetBg$resetFg'
        ' Default text again $reset';
    print(text);
  },
);

Unfortunately, the log method outputs long messages (more than 128 characters) as <collected>. And it is easy to exceed the allowed size when using escape codes. In the example above, the text does not fit in this size if RGB colors are used.

And secondly, log works only from IDE. Testers who don't use IDE won't see anything in the console.

So in most cases on iOS, it's left to disable escape codes for the most part:

runZonedPrinter(
  defaultStyle: …,
  ansiCodesEnabled: !Platform.isIOS,
  () {
    const text = ' Default text '
        '$bgWhite$fgBlack Highlighted text $reset'
        ' Default text again';
    print(text);
  },
);

StackedPrinter #

Escape codes allow you to do simple text decoration. But a slightly more complex design requires much more effort. One example is given above, when you need a default style different from the one provided by the terminal.

This is an explicit hierarchical interpretation, not a model of the state a terminal reaches from arbitrary ANSI. A terminal's selective reset clears a property to its default; StackedPrinter reads the same code as one pop. Use Printer when the terminal meaning of the input must be preserved, and use StackedPrinter when resets close nested style operations such as the template below.

Imagine that you have a template for text into which you will insert other text, that is sent to you externally. But the person who sends you this text decides to highlight it:

String makeMessage(String name) {
  const template = 'Dear {name}! We are pleased to present to you …';

  return template.replaceAll('{name}', name);
}

…

const name = '${bold}Sam$resetBoldAndDim';

…

final text = makeMessage(name);
print(text);
// Dear [bold]Sam[resetBoldAndDim]! We are pleased to present to you …

Without noticing it, at some point your designer decides to make changes to the template:

const template = '${bold}Dear {name}, welcome to us!$resetBoldAndDim We are pleased to present to you …';

…

final text = makeMessage(name);
print(text);
// [bold]Dear [bold]Sam[resetBoldAndDim], welcome to us![resetBoldAndDim] We are pleased to present to you …

But the escape codes don't accumulate, double bold equals single bold. And first resetBoldAndDim cancels the bold text. And we don't get what we want at all. To fix it, we need to return the state of the text after insertion to the state it was before insertion. But it makes it much more difficult to use the escape codes. StackedPrinter helps solve this problem:

final printer = StackedPrinter();
printer.print(text);
// [reset][bold]Dear Sam, welcome to us![reset] We are pleased to present to you …

StackedPrinter accumulates state changes and sequentially disables them, translating the current state into the standard escape sequence on output:

const text = '$bold 1 $bold 2 $bold 3 $resetBoldAndDim 2 $resetBoldAndDim 1 $resetBoldAndDim';
final printer1 = Printer();
final printer2 = StackedPrinter();
printer1.print(text); // '[reset][bold] 1  2  3 [reset] 2  1 '
printer2.print(text); // '[reset][bold] 1  2  3  2  1 [reset]'

Printing to a sink #

Printer and StackedPrinter hand their output to a print function. SinkPrinter and StackedSinkPrinter write it to a StringSink instead — a StringBuffer, a file, stdout — and keep the style across the writes:

final buf = StringBuffer();
SinkPrinter(buf, defaultStyle: Styles.bgGray3)
  ..write('one ')
  ..write('${fgRed}two$reset');

print(Parser(buf.toString()).showControlFunctions());
// [reset][bg256Gray3]one [reset][reset][bg256Gray3][fgRed]two[reset]

Both take the same defaultStyle as the others, and both take ansiCodesEnabled. Setting it to false writes the text without any escape codes at all — the codes the text carries included:

final plain = StringBuffer();
SinkPrinter(plain, ansiCodesEnabled: false).write('${fgRed}two$reset');
print(plain); // two

That is the switch for output that is not a terminal. NoStyle is a different thing: it stops the printer from putting a style of its own around the text, but the codes the text carries still go through.

A write may stop in the middle of a sequence — a chunk taken off a stream falls where it falls — and what it cannot finish waits for the write that does, so the same bytes read the same however the writes fall across them. flush says that no such write is coming:

final buf = StringBuffer();
final printer = SinkPrinter(buf)..write('a\x1B]0;title');

print(buf.length); // 5 — the title is still waiting to be finished
printer.flush(); // it goes out terminated, and no newline with it

Printer and StackedPrinter hold a line until a writeln ends it, and flush lets go of that too.

A default style for everything printed #

You cannot set default colors for the entire terminal. However, Dart allows you to intercept calls to the print and override the default style in those calls.

Example (if you use styles):

import 'package:ansi_escape_codes/style.dart';

void main() {
  final greenStyle = Styles.green.bold;
  final highlighedStyle = Styles.red.bgYellow.underline;

  runZonedPrinter(
    defaultStyle: Styles.gray12,
    () {
      print(
        'Normal text'
        ' ${greenStyle('Green ${highlighedStyle('Highlighted text')} text')}'
        ' Normal text',
      );
    },
  );
}

If you are using ready-to-use values, you can also use runZonedPrinter. But in this case, all reset... functions will return defaultStyle:

import 'package:ansi_escape_codes/ansi_escape_codes.dart';

void main() {
  runZonedPrinter(
    defaultStyle: const Style(
      foreground: Color256.gray12,
    ),
    () {
      print(
        'Normal text'
        ' ${fgGreen}Green text ${fgRed}Highlighted text$resetFg Not a green text$resetFg'
        ' Normal text',
      );
    },
  );
}

If you need nested styles, use runZonedStackedPrinter:

import 'package:ansi_escape_codes/ansi_escape_codes.dart';

void main() {
  runZonedStackedPrinter(
    defaultStyle: const Style(
      foreground: Color256.gray12,
    ),
    () {
      print(
        'Normal text'
        ' ${fgGreen}Green text ${fgRed}Highlighted text$resetFg Green text$resetFg'
        ' Normal text',
      );
    },
  );
}

Note

The two ways of writing a colour can be used side by side, and ansi_escape_codes.dart brings both: bold is the string of escape codes, Styles.bold is the style. See the names this package brings.

Logging #

Nothing has to be tied to a logging package: a record is a string and the constants are strings, so coloring a level name needs no help. What does need help are the two places where escape codes bite.

The first is width. String.length counts the escape codes, so padding a colored level name pads it by the wrong amount. Parser counts the same UTF-16 code units without the codes — 𝄞 is still two, as everywhere in Dart, and an insertion never lands inside a surrogate pair:

const level = '${fgRed}SEVERE$reset';
print(level.length); // 15
print(Parser(level).length); // 6
print('[${level.padRight(10)}]'); // [SEVERE] — the codes ate the padding
print('[${Parser(level).padRight(10)}]'); // [SEVERE    ]
print('[${Parser(level).padLeft(10)}]'); // [    SEVERE]

A padding of more than one character overshoots the width, the way String.padRight overshoots it: it is written once for every character still wanted, not once for every place it fills.

The second is the sink. A terminal reads the codes, a log file keeps them as bytes nobody will read back, so the same line goes out twice in two shapes:

void write(String line) {
  stdout.writeln(line);
  logFile.writeAsStringSync('${line.ansiRemoveEscapeCodes()}\n',
      mode: FileMode.append);
}

stdout and FileMode come from dart:io, not from this package; logFile is whatever file the program opened.

And a message that arrives already styled from elsewhere is the case StackedPrinter was written for: whatever the message opens is closed at its end, and the next line starts in the style it should.

Reading #

A string that already carries escape codes is what Parser is for: what it says with the codes taken out, how long it is without the codes, what style is in force at any point of it, and what every sequence in it means.

Parser #

Parser allows you to analyze text containing escape codes. There are two of them, and everything below holds for both: Parser keeps the style in force at each point, StackedParser keeps the history of how it got there, so that a resetFg goes back to the color before the last one rather than to the terminal's own. The difference is the one between Printer and StackedPrinter, and the state it hands out is a Stack instead of a Style.

That difference is semantic, not only additional bookkeeping. Use Parser to ask what a terminal shows for arbitrary ANSI; choose StackedParser only when its resets are meant to close the most recently applied style level.

import 'package:ansi_escape_codes/ansi_escape_codes.dart';

const text = '$bold Bold $fgCyan Bold+cyan $resetBoldAndDim Cyan ';
final parser = Parser(text);
parser.pieces.forEach(print);
// Piece<Style>(start: 0, end: 4, entity: Sgr(bold), state: Style(bold), link: null)
// Piece<Style>(start: 4, end: 10, entity: Text(' Bold '), state: Style(bold), link: null)
// Piece<Style>(start: 10, end: 15, entity: Sgr(fgCyan), state: Style(bold, foreground: Color16.cyan), link: null)
// Piece<Style>(start: 15, end: 26, entity: Text(' Bold+cyan '), state: Style(bold, foreground: Color16.cyan), link: null)
// Piece<Style>(start: 26, end: 31, entity: Sgr(resetBoldAndDim), state: Style(foreground: Color16.cyan), link: null)
// Piece<Style>(start: 31, end: 37, entity: Text(' Cyan '), state: Style(foreground: Color16.cyan), link: null)

In this way we can, for example, remove all escape codes:

final parser = Parser('$bold Bold $fgCyan Bold+cyan $resetBoldAndDim Cyan ');
final buf = StringBuffer();
for (final m in parser.pieces) {
  switch (m.entity) {
    case Text(:final string):
      buf.write(string);
    case EscapeCode():
      break;
  }
}
print(buf); // ' Bold  Bold+cyan  Cyan '

There is a ready-to-use method for this:

print(parser.removeAll());

Or replace the escape codes with a readable form:

final parser = Parser('$bold Bold $fgCyan Bold+cyan $resetBoldAndDim Cyan ');
final buf = StringBuffer();
for (final m in parser.pieces) {
  final result = switch (m.entity) {
    EscapeCode(:final id) => '[$id]',
    Text(:final string) => string,
  };
  buf.write(result);
}
print(buf); // [bold] Bold [fgCyan] Bold+cyan [resetBoldAndDim] Cyan

You can also use ready-to-use methods for this:

print(parser.replaceAll((e) => '[${e.id}]'));
print(parser.showControlFunctions());

You can find out the length of plain text without escape codes using length:

print(parser.length == parser.removeAll().length); // true
print(parser.length); // 23

The style at a particular position can be found with stateAt.

final parser = Parser('$bold Bold $fgCyan Bold+cyan $resetBoldAndDim Cyan ');
final atSeven = parser.stateAt(7);
print(atSeven); // Style(bold, foreground: Color16.cyan)
print(atSeven.isBold); // true
print(atSeven.isItalic); // false
print(atSeven.foregroundColor?.id); // fgCyan
print(atSeven.backgroundColor?.id); // null

The position in stateAt is specified in the plaintext range (pos < parser.length) and can also point to the position behind the text (pos == parser.length) to find out the final state. The final state can also be obtained using finalState.

print(parser.stateAt(23) == parser.finalState); // true
print(parser.finalState); // Style(foreground: Color16.cyan)

A hyperlink is state but not style, so it is not in what stateAt answers: linkAt and finalLink are its pair of the same two questions, on a channel of their own. See Hyperlinks.

Reading happens as late as it can. stateAt reads the string up to the position asked about and stops there, and what it read is kept, so the next question picks up where the last one left off instead of starting over:

final parser = Parser('$bold one $fgCyan two $resetBoldAndDim three ');
parser.stateAt(2); // reads as far as the third character
parser.finalState; // reads on from there, not from the beginning

It keeps its place as well as its reading, so asking about position after position — which is what laying text out does — costs one walk of the string in all rather than one walk each. Going back is allowed and starts the walk over.

prepare reads the whole string in one go and builds the plain text that length, indexOf, contains and the rest of the string methods work on:

final parser = Parser(text)..prepare();

Those methods are what it is for. stateAt, linkAt and substring do not gain by it, and lose by it where the questions are not going to reach the end of the string. benchmark/ measures both.

In the above example, the text state was not set to default, i.e. the text was not closed:

const text = '$bold Bold $fgCyan Bold+cyan $resetBoldAndDim Cyan ';
final parser = Parser(text);
print(parser.isClosed); // false

The easiest way to close a text is to add a reset at the end of it:

const closedText = '$text$reset';
print(Parser(closedText).isClosed); // true

The substring method allows you to retrieve a piece of text by computing together its state:

final parser = Parser('$bold Bold $fgCyan Bold+cyan $resetBoldAndDim Cyan ');
final substr = parser.substring(7, maxLength: 9); // "Bold+cyan"
print(Parser(substr).showControlFunctions()); // [fgCyan;bold]Bold+cyan[reset]

By default, the substring is closed. Escape codes is always included in the string in optimized form:

final parser = Parser('$bold Bold $fgCyan Bold+cyan $resetBoldAndDim Cyan ');
final substr = parser.substring(7, maxLength: 9); // "Bold+cyan"
const test1 = '$fgCyan$bold';
final test2 = substr.substring(0, substr.indexOf('Bold'));
print(test1.ansiShowEscapeSequences()); // [CSI 36 SGR][CSI 1 SGR]
print(test2.ansiShowEscapeSequences()); // [CSI 36;1 SGR]
print(Parser(test1).showControlFunctions()); // [fgCyan][bold]
print(Parser(test2).showControlFunctions()); // [fgCyan;bold]
print(test1.length); // 9
print(test2.length); // 7

To optimize the entire string, there is an optimize method:

const text = '$fgWhite$bold$resetBoldAndDim$fgGreen$underline'
    "$resetUnderline$dim$dim What's in here? $resetBoldAndDim$resetFg";
print(text.length); // 63
final parser = Parser(text);
print(parser.showControlFunctions());
// [fgWhite][bold][resetBoldAndDim][fgGreen][underline][resetUnderline][dim][dim] What's in here? [resetBoldAndDim][resetFg]

final optimizedText = parser.optimize();
print(optimizedText.length); // 28
print(Parser(optimizedText).showControlFunctions());
// [fgGreen;dim] What's in here? [reset]

The insertBefore and insertAfter methods put text into a string without disturbing what is already there. The inserted text takes the style of the place it lands in, and whatever codes it carries of its own are closed after it, so the rest of the string keeps the look it had:

const text = '${fgRed}Hello world$reset';
final inserted = Parser(text).insertBefore(6, '${fgGreen}brave ');
print(Parser(inserted).showControlFunctions());
// [fgRed]Hello [fgGreen]brave [fgRed]world[reset]

If the inserted text itself ends inside an escape sequence the parser could not finish, the sequence is terminated before the original tail follows it. This is the other side of the unfinished-input rule below: that rule keeps the insertion out of the input's sequence; this one keeps the input's tail out of the insertion's sequence.

The position is counted in the string without escape codes, as everywhere else in Parser. The two methods part ways only when escape codes stand at that very position: one goes in front of them, the other behind:

const text = '${fgRed}Hello$reset world';
print(Parser(text).insertBefore(5, '!').ansiShowControlFunctions());
// [fgRed]Hello![reset] world
print(Parser(text).insertAfter(5, '!').ansiShowControlFunctions());
// [fgRed]Hello[reset]! world

Neither insertion lands inside a sequence the parser could not finish — a control string that never got its terminator, be it an OSC, a DCS, an SOS, a PM or an APC; a bare ESC; a CSI with no final byte; an ESC left on an intermediate byte. Whatever is written among the bytes of one is read as part of it, so the text goes in front of the sequence and the tail is copied on as it came:

print(Parser('aa\x1B]0;title').insertAfter(2, 'X')); // 'aaX\x1B]0;title'

Where several of them stand in a row, the text goes in front of the whole run: a gap between two unfinished codes is no seam but the inside of the first. A finished code ends the run and is passed along with what stands before it, so the run stood in front of is the one reaching the text rather than everything unfinished in the string:

print(Parser('aa\x1B]0;title\x1B(B').insertAfter(2, 'X'));
// 'aa\x1B]0;title\x1B(BX'

The last bytes of such a sequence come back as text although a terminal reads them as part of it. The parameters of a CSI with no final byte are the case worth naming, but any byte no sequence can be built from — a LF, a DEL, a letter outside ASCII — breaks off the pattern just as well and leaves the code in front of it waiting for its ending all the same. A position among those bytes has no right answer — in front of the sequence is before characters counted in front of it, and where it was asked for is inside the sequence — so both insertions refuse it:

Parser('aa\x1B[31').insertAfter(3, 'X'); // throws UnfinishedSequenceException

The seam itself is refused where it is one of those positions: a run beginning behind such a piece of text begins among bytes the sequence in front of the text is still reading, so the place before the run is where that sequence's ending would be written. A code that stands finished between the text and the run gives the run a seam of its own, and that one is served.

The exception carries the position asked for and the offset the sequence it would have been read as part of begins at. A position outside the plain text is a RangeError, as everywhere else.

For a string parsed only once there are the ansiInsertBefore and ansiInsertAfter extensions, like the other shortcuts below.

Quick analysis #

You can quickly analyze a string without using Parser by using extensions. They work by regular expression, where Parser builds an entity for every code it meets, and the difference between the two tells where one answer is all that is wanted: an ansiHas question stops at the first code that answers it, and a string carrying no codes at all is turned away by a contains(ESC) — the parser scans it just as fast, but has a list of pieces to build where the extension has nothing. Where the whole string is to be walked anyway, the parser is not the dearer of the two — on a page of colored log ansiRemoveEscapeCodes costs a little more than Parser.removeAll does, and the parse, once made, answers everything else asked of it.

import 'package:ansi_escape_codes/ansi_escape_codes.dart';

…

const text = '${fgRed}ERROR$reset';
print(text.ansiHasEscapeCodes); // true
print(text.ansiHasCsi); // true
print(text.ansiHasSgr); // true
print(text.ansiHasForeground); // true
print(text.ansiHasBackground); // false
print(text.ansiShowEscapeSequences()); // [CSI 31 SGR]ERROR[CSI 0 SGR]

The method ansiShowControlCodes allows to show all control codes in a string — the C0 set, DEL and the eight-bit C1:

const text = 'Tab: \t Line feed: \n Carriage return: \r Bell: \x07';

print(text.ansiShowControlCodes()); // preferStyle: ControlCodeStyle.escapeOrCharCode
// Tab: \t Line feed: \n Carriage return: \r Bell: \x07

print(text.ansiShowControlCodes(preferStyle: ControlCodeStyle.charCode));
// Tab: \x09 Line feed: \x0A Carriage return: \x0D Bell: \x07


print(text.ansiShowControlCodes(preferStyle: ControlCodeStyle.abbr));
// Tab: [HT] Line feed: [LF] Carriage return: [CR] Bell: [BEL]

print(text.ansiShowControlCodes(preferStyle: ControlCodeStyle.escapeOrAbbr));
// Tab: \t Line feed: \n Carriage return: \r Bell: [BEL]

print(text.ansiShowControlCodes(preferStyle: ControlCodeStyle.unicode));
// Tab: ␉ Line feed: ␊ Carriage return: ␍ Bell: ␇

print(text.ansiShowControlCodes(preferStyle: ControlCodeStyle.escapeOrUnicode));
// Tab: \t Line feed: \n Carriage return: \r Bell: ␇

The eight-bit forms of the C1 controls — the bytes 0x80 through 0x9F — are shown too, as the number of the byte and in every style alike, since they have neither an abbreviation nor a Unicode picture of their own:

print('a\u{9B}b'.ansiShowControlCodes()); // a\x9Bb
print('a\u{9B}b'.ansiShowControlCodes(preferStyle: ControlCodeStyle.abbr)); // a\x9Bb
print('a\u{9B}b'.ansiHasControlCodes); // true
print('a\u{9B}b'.ansiRemoveControlCodes()); // ab

They are controls by Unicode's own category and a terminal handed one prints rubbish rather than a glyph, so a string cleaned for display is not clean while they stand in it. 0xA0 and above are not controls and are left alone.

Being controls does not make them escape codes here, and this package does not read them as such: Parser reads 0x9B as text rather than as a CSI, and ansiRemoveEscapeCodes leaves it where it stands. That is a decision and not an omission. What is parsed here are decoded Dart strings rather than byte streams, and a genuine eight-bit C1 does not survive UTF-8 decoding — it arrives whole only from a stream decoded as latin1, where the caller already knows it is holding eight-bit bytes. Terminals emit the seven-bit ESC [ form by default, so reading 0x9B as a CSI would break honest text more often than it would help.

You can quickly remove all codes using the methods:

const text =
    '$saveCursor$cursorRight$italic$bgGreen$fgYellow Text $resetFg$resetBg$resetItalic$restoreCursor';
print(Parser(text).showControlFunctions());
// [saveCursor][CSI CUF][italic][bgGreen][fgYellow] Text [resetFg][resetBg][resetItalic][restoreCursor]

final withoutBackground = text.ansiRemoveBackground();
print(Parser(withoutBackground).showControlFunctions());
// [saveCursor][CSI CUF][italic][fgYellow] Text [resetFg][resetItalic][restoreCursor]

final andWithoutForeground = withoutBackground.ansiRemoveForeground();
print(Parser(andWithoutForeground).showControlFunctions());
// [saveCursor][CSI CUF][italic] Text [resetItalic][restoreCursor]

final andWithoutSgr = andWithoutForeground.ansiRemoveSgr();
print(Parser(andWithoutSgr).showControlFunctions());
// [saveCursor][CSI CUF] Text [restoreCursor]

final andWithoutCsi = andWithoutSgr.ansiRemoveCsi();
print(Parser(andWithoutCsi).showControlFunctions());
// [saveCursor] Text [restoreCursor]

final withoutAllEscapeCodes = text.ansiRemoveEscapeCodes();
print(withoutAllEscapeCodes.ansiShowEscapeSequences());
// ' Text '

The rest of the extensions, in one breath: ansiHasUnderlineColor and ansiRemoveUnderlineColor do for the color of the underline what the pairs above do for the foreground and the background; ansiHasControlCodes and ansiRemoveControlCodes ask about and take out the control codes — the C0 bytes, DEL and the eight-bit C1 — rather than the escape codes; ESC is one of those bytes, so take the escape codes out first or their bodies are left behind as text, and name the ones to keep with exclude: {ControlFunctionsC0.LF}, which names C0 members and so cannot spare an eight-bit C1; lengthWithoutEscapeCodes is Parser.length for a string read once; ansiShowControlFunctions and ansiOptimizeControlFunctions are Parser.showControlFunctions and Parser.optimize for a string read once.

Sequence types #

The sequences that carry something worth reading say it themselves, so a switch over pieces can ask for the sequence and for what it holds in one pattern:

final text = '${cursorUpN(4)}$erasePage Hello $hideCursor';

for (final m in Parser(text).pieces) {
  switch (m.entity) {
    case CursorUp(:final n):
      print('the cursor goes up $n lines');
    case EraseInPage(:final part):
      print('the page is erased: $part');
    case HideCursor():
      print('the cursor is hidden');
    case Text(:final string):
      print('the text says "$string"');
    default:
  }
}
// the cursor goes up 4 lines
// the page is erased: ErasePart.all
// the text says " Hello "
// the cursor is hidden

CursorUp, CursorDown, CursorRight, CursorLeft, CursorNextLine, CursorPrevLine, CursorHPos, ScrollUp and ScrollDown carry n, the number of places to move by, which is 1 when the sequence leaves it out. CursorPos and CursorHVPos carry row and col. EraseInPage and EraseInLine carry an EraseParttoEnd, toBegin or all. ShowCursor, HideCursor, UseAlternateScreen and UseMainScreen stand for the four private modes this package writes itself.

All but the last four extend CsiCommon, so is CsiCommon, controlSequence and the identifiers they are shown by are what they always were; the four private modes stand beside CsiPrivate instead, the way SaveCursor stands beside the other ESC sequences, and are shown by the name of the constant they are written with.

A sequence carrying something other than what its type promises — two parameters where one is taken, or a part ErasePart has no name for, like the xterm CSI 3 J — keeps its parameters and stays a plain CsiCommon rather than being given made-up values.

Every recognized sequence keeps its parameters as they were written, in params: a CsiParamNumber where the parameter is a number, a CsiParamDefault where it was left out, and a CsiParamNumbers where it carries sub-parameters after a colon. The colon form is read as the standard means it, so CSI 4:3 m is an underline and CSI 38:2::51:102:153 m is an RGB color, and what the sub-parameters say beyond that — a curly underline rather than a straight one — is in params rather than in the style:

const text = '\x1B[4:3m wavy \x1B[;5H\x1B[38:2::51:102:153m';

for (final m in Parser(text).pieces) {
  if (m.entity case Sgr(:final params, :final id) ||
      CsiCommon(:final params, :final id)) {
    print('$id  $params');
  }
}
// underline  [4:3]
// CSI ;5 CUP  [, 5]
// fgRgb(51,102,153)  [38:2:0:51:102:153]

Unknown sequences #

The parser never throws on what it cannot name. Whatever it fails to recognize comes back as an entity of its own with the raw bytes kept intact: CsiUnknown, EscUnknown, OscUnknown and UnknownEscapeCode for what has no meaning here, Dcs, Sos, Pm and Apc for the control strings this package carries without reading, and CsiPrivate for the private-use sequences, whose meaning the standard leaves to the terminal. All of them carry the UnrecognizedEscapeCode mixin, so a single check covers them:

const text = 'a\x1B[!pb\x1B[?7hc';
for (final m in Parser(text).pieces) {
  if (m.entity case final UnrecognizedEscapeCode e) {
    print('${m.start}..${m.end}: ${e.id}');
  }
}
// 1..5: CSI !p
// 6..11: CSI ?7 SM

start and end are positions in the original string, so a complaint can point at the bytes it is about.

To put something else in their place, replaceAll walks the string once and writes back whatever is returned:

print(Parser(text).replaceAll((e) => e is UnrecognizedEscapeCode ? '?' : e.string));
// a?b?c

OSC 8 is what makes text clickable, and link writes the whole of it — the opening, the text shown, and the close:

print(link('https://dart.dev').ansiShowControlFunctions());
// [link(https://dart.dev)]https://dart.dev[linkClose]
print(link('https://dart.dev', text: 'the site').ansiShowControlFunctions());
// [link(https://dart.dev)]the site[linkClose]

The pieces have names of their own for a link built as a constant — ${linkOpen}$url$linkTextOpen$text$linkClose — and linkBel writes the older form, ended by a BEL where the other ends by an ST. Terminals take either.

A control byte in the url is written as its percent-escape. An ESC in the body of an OSC 8 ends the sequence where it stands, so what was meant as the rest of the address would reach the terminal as codes of its own. An address carrying none — which is every address that is one — comes out byte for byte, its own percent-escapes untouched. The pieces above put the url in unchecked, so an address from a source you do not control wants link rather than the constants. The text shown is written as it came, styling and all.

A link is state, the way a style is: what is written after an opening is inside it until a close. Links do not nest — an opening supersedes the one before it, and one close ends whatever was open — so the parser keeps them on a channel beside the style rather than in it, and answers for them on their own:

final parser = Parser('see ${link('https://dart.dev', text: 'the site')} now');
print(parser.linkAt(4)?.url); // https://dart.dev
print(parser.linkAt(0)); // null
print(parser.finalLink); // null

linkAt takes a position of the text without escape codes, as stateAt does, and answers with the link the character there sits inside. Both read from the same walk, so asking each of them about a run of positions costs one pass over the string in all. finalLink is what the string leaves open — null here, the text having closed what it opened. Walking the pieces yourself, every Piece carries its link beside its state.

isClosed is a question about the style alone: a string that ends in the state it began in but leaves a hyperlink open answers true, so finalLink is the one to ask beside it.

A slice keeps the text clickable. One that begins inside a link opens that link again in front of its first piece of text, and closes it at the end, so a line cut out of a document stands on its own:

final parser = Parser('see ${link('https://dart.dev', text: 'the site')} now');
print(Parser(parser.substring(4, maxLength: 3)).showControlFunctions());
// [link(https://dart.dev)]the[linkClose]

The opening is written again in the bytes it was written in the first place: a link opened BEL-terminated is opened BEL-terminated again, and an id= — which is what OSC 8 gives for a link a line break cuts in two — travels with it. The close is always the ST-terminated one, and terminals take it after either opening:

final parser = Parser('see ${linkBel('https://dart.dev', text: 'the site')} now');
print(parser.substring(4, maxLength: 3).ansiShowEscapeSequences());
// [OSC 8;;https://dart.dev BEL]the[OSC 8;; ST]

substring(close: false) leaves the link open, as it leaves the style open, and optimize closes one the string left open the same way substring does.

The printers carry a link from line to line. A line closes the one it leaves open — what is printed after it must not stay clickable on that URL — and the line after opens it again, so a link a newline falls inside of goes on being one link:

final lines = <String>[];
Printer(output: lines.add)
    .print('${linkOpen}https://dart.dev${linkTextOpen}first\nsecond$linkClose');
for (final line in lines) {
  print(Parser(line).showControlFunctions());
}
// [reset][link(https://dart.dev)]first[linkClose]
// [reset][link(https://dart.dev)]second[linkClose]

SinkPrinter and StackedSinkPrinter take a write at a time, and a line there may be composed of several: a link opened by one write stays open across the writes that follow, and the close falls where the line really ends — at a writeln, or at a '\n' in what is written. An insertion gives a link back the same way: what follows insertBefore or insertAfter goes on pointing where it pointed before, whatever the inserted text opened of its own.

example/links.dart puts a link broken across three lines in front of a real terminal, printed and sliced, so the clicking can be tried rather than read about.

Utilities #

Two things a terminal will only tell or take in person, both in utils.dart — which is asked for by name, since it is the one entry point that reaches dart:io:

import 'package:ansi_escape_codes/utils.dart';

tabs sets the tabulation stops. The stops that were there are always cleared first, so a call without arguments leaves the terminal with none — it does not bring back the ones it started with:

tabs(defaultTab: 4); // a stop every 4 columns, to the width of the terminal
tabs(tabs: [8, 4, 4]); // one stop 8 columns along, then two 4 more each

The numbers are distances, not column numbers, and the run starts at the left edge — so [8, 4, 4] sets its stops in columns 9, 13 and 17. defaultTab sets one in the first column as well, which a list does not.

Nothing is written when stdout is not a terminal: there are no stops to set and no width to fit them into. A distance reaching past the width sets no stop and ends the run there.

currentCursorPos asks the terminal where the cursor is — CSI 6 n out, and CSI n ; m R back through stdin:

final (row, col) = await currentCursorPos(stdout, stdin);

stdout and stdin here are dart:io's, not this package's.

The terminal is given 100 milliseconds to answer by default; a terminal that does not answer at all throws UnsupportedError. Stdin can only be listened to once, so to ask twice — or to keep reading input afterwards — pass a broadcast stream over it as input.

0
likes
160
points
142k
downloads

Documentation

API reference

Publisher

verified publisheryet-another.dev

Weekly Downloads

A toolkit for working with ANSI escape codes and analyzing strings containing them.

Repository (GitHub)
View/report issues

Topics

#ansi #escape-codes #console #color #cli

License

MIT (license)

Dependencies

meta

More

Packages that depend on ansi_escape_codes