blob: 355c650f4cdfafbbbc43fdf500fd0c27b80e22ee [file] [log] [blame]
// Copyright (c) 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:io' as io;
import 'dart:math';
typedef String CellTextCallback(dynamic item);
enum ALIGNMENT { left, center, right }
enum TEXTBEHAVIOUR { truncateLeft, truncateRight, wrap }
/// [OutputTable] defines a base class for outputting tabular data to outputs.
abstract class OutputTable {
void addHeader(Column column, CellTextCallback callback);
void print(List items);
void printToSink(List items, StringSink sink);
}
/// [ScripTable] outputs items without any formatting except tabs for
/// separators.
class ScriptTable extends OutputTable {
final Map<Column, CellTextCallback> _columns = {};
@override
void addHeader(Column column, CellTextCallback callback) {
_columns[column] = callback;
}
@override
void print(List items) {
printToSink(items, io.stdout);
}
@override
void printToSink(List items, StringSink sink) {
if (_columns.length == 0) {
return;
}
// Print headers.
var columns = _columns.keys.toList();
columns.forEach((column) {
if (column != columns[0]) {
sink.write("\t");
}
sink.write(column.header);
});
sink.writeln("");
// Print items.
items.forEach((item) {
columns.forEach((column) {
if (column != columns[0]) {
sink.write("\t");
}
sink.write(_columns[column](item));
});
sink.writeln("");
});
}
}
/// [ConsoleTable] outputs a list of items as a table, width a default width of
/// 80 units. Column sizes can be set individually. If zero-width for a column
/// is specified, the free available space is distributed equally amongst these.
/// The table can be styled by giving it a custom template.
class ConsoleTable extends OutputTable {
final int width;
final Template template;
final Map<Column, CellTextCallback> _columns = {};
ConsoleTable({this.width = 80, this.template = minimal});
/// [addHeader] adds a new column to the table. When a row is processed the
/// callback given will be invoked with the item for the row.
@override
void addHeader(Column column, CellTextCallback callback) {
_columns[column] = callback;
}
/// Prints the [items] as a table in columns given by [addHeader] to stdout.
@override
void print(List items) {
printToSink(items, io.stdout);
}
/// Prints the [items] as a table in columns given by [addHeader] to [sink].
@override
void printToSink(List items, StringSink sink) {
if (_columns.length == 0) {
return;
}
// Precompute allocated width.
var allocatedWidth = 0;
var zeroWidthColumns = 0;
_columns.keys.forEach((column) {
if (column.width == 0) {
zeroWidthColumns++;
} else {
allocatedWidth += column.width;
}
});
// Free width is the default width, subtracted by the already allocated
// width and the column separators.
var freeWidth = width -
allocatedWidth -
(_columns.length - 1) * template.columnDivider.length -
2 * template.leftRightFrame.length;
if (zeroWidthColumns > 0 && freeWidth > 0) {
// Distribute free width among columns with 0 width;
var cellWidth = (freeWidth / zeroWidthColumns).round();
_columns.keys.forEach((column) {
if (column.width != 0) {
return;
}
if (zeroWidthColumns == 1) {
column._computedWidth = freeWidth;
} else {
column._computedWidth = cellWidth;
freeWidth -= cellWidth;
zeroWidthColumns--;
}
});
}
// At this point, all column widths are either calculated or should not be
// rendered.
_printTopBottomDivider(sink);
_printHeader(sink);
_printHeaderDivider(sink);
bool isFirst = true;
items.forEach((item) {
if (!isFirst) {
_printRowDivider(sink);
}
var cellStrings = _columns.keys
.where((column) => column._computedWidth > 0)
.map((column) {
var strings = _breakText(_columns[column](item) ?? "",
column._computedWidth, column.headerBehaviour);
return strings
.map((str) =>
_alignPadText(str, column._computedWidth, column.alignment))
.toList();
}).toList();
_printRowAlignTop(cellStrings, sink);
isFirst = false;
});
_printTopBottomDivider(sink);
}
void _printTopBottomDivider(StringSink sink) {
if (template.topBottomFrame.length > 0) {
sink.writeln("${template.cornerJoin}"
"${template.topBottomFrame * (width - 2 * template.cornerJoin.length)}"
"${template.cornerJoin}");
}
}
void _printHeaderDivider(StringSink sink) {
if (template.headerDivider.length > 0) {
sink.writeln("${template.rowJoin}"
"${template.headerDivider * (width - 2 * template.rowJoin.length)}"
"${template.rowJoin}");
}
}
void _printRowDivider(StringSink sink) {
if (template.rowDivider.length > 0) {
sink.write(template.rowJoin);
bool isFirst = true;
_columns.keys.forEach((column) {
if (column._computedWidth > 0) {
if (!isFirst) {
sink.write(template.cellJoin);
}
sink.write(template.rowDivider * column._computedWidth);
isFirst = false;
}
});
sink.writeln(template.rowJoin);
}
}
void _printHeader(StringSink sink) {
var cellStrings = _columns.keys
.where((column) => column._computedWidth > 0)
.map((column) {
var strings = _breakText(
column.header ?? "", column._computedWidth, column.headerBehaviour);
var cellStrings = strings
.map((str) =>
_alignPadText(str, column._computedWidth, column.alignment))
.toList();
return cellStrings;
}).toList();
_printRowAlignBase(cellStrings, sink);
}
void _printRowAlignBase(List<List<String>> strings, StringSink sink) {
var height = strings.fold(
0, (prevValue, strings) => max<int>(prevValue, strings.length));
for (var i = 0; i < height; i++) {
for (var j = 0; j < _columns.length; j++) {
sink.write(j == 0 ? template.leftRightFrame : template.columnDivider);
if (height - i - 1 >= strings[j].length) {
sink.write(' ' * strings[j][0].length);
} else {
var index = i - (height - strings[j].length);
sink.write(strings[j][index]);
}
}
sink.writeln(template.leftRightFrame);
}
}
void _printRowAlignTop(List<List<String>> strings, StringSink sink) {
var height = strings.fold(
0, (prevValue, strings) => max<int>(prevValue, strings.length));
for (var i = 0; i < height; i++) {
for (var j = 0; j < _columns.length; j++) {
sink.write(j == 0 ? template.leftRightFrame : template.columnDivider);
if (i >= strings[j].length) {
sink.write(' ' * strings[j][0].length);
} else {
sink.write(strings[j][i]);
}
}
sink.writeln(template.leftRightFrame);
}
}
List<String> _breakText(String text, int width, TEXTBEHAVIOUR textBehaviour) {
if (text.length <= width) {
return [text];
}
if (textBehaviour == TEXTBEHAVIOUR.truncateLeft ||
textBehaviour == TEXTBEHAVIOUR.truncateRight) {
String truncateString = width <= 4 ? ".." : "...";
int substringLength = (width - truncateString.length);
String truncatedString = textBehaviour == TEXTBEHAVIOUR.truncateLeft
? "${truncateString}${text.substring(text.length - substringLength)}"
: "${text.substring(0, substringLength)}${truncateString}";
if (truncatedString.length > width) {
return [text.substring(0, 1)];
}
return [truncatedString];
}
// We have to wrap - see if we can find any good places to break.
List<String> wrappedStrings = [];
int finger = 0;
var regexpBlank = new RegExp(r"[ ]");
var regexpIncludeChars = new RegExp(r"[\-_]");
while (finger + width < text.length) {
var nicerIndex = text.lastIndexOf(regexpBlank, finger + width);
if (nicerIndex > finger) {
wrappedStrings.add(text.substring(finger, nicerIndex));
finger = nicerIndex + 1; // change space for return;
continue;
}
nicerIndex = text.lastIndexOf(regexpIncludeChars, finger + width - 1);
if (nicerIndex > finger) {
wrappedStrings.add(text.substring(finger, nicerIndex + 1));
finger = nicerIndex + 1;
continue;
}
wrappedStrings.add(text.substring(finger, finger + width));
finger = finger + width;
}
var lastSubString = text.substring(finger);
if (!lastSubString.isEmpty) {
wrappedStrings.add(lastSubString);
}
return wrappedStrings;
}
String _alignPadText(String text, int width, ALIGNMENT alignment) {
switch (alignment) {
case ALIGNMENT.left:
return text.padRight(width);
case ALIGNMENT.right:
return text.padLeft(width);
case ALIGNMENT.center:
int half = ((width / 2) + (text.length) / 2).round();
String str = text.padLeft(half);
str = str.padRight(width);
return str;
}
return ""; // Stupid type-checker.
}
}
/// [Column] defines a column in a table and should be given a descriptive
/// header.
class Column {
final String header;
final int width;
final ALIGNMENT alignment;
final TEXTBEHAVIOUR headerBehaviour;
final TEXTBEHAVIOUR cellBehaviour;
int _computedWidth;
Column(this.header,
{this.width = 0,
this.alignment = ALIGNMENT.left,
this.headerBehaviour = TEXTBEHAVIOUR.wrap,
this.cellBehaviour = TEXTBEHAVIOUR.wrap})
: _computedWidth = width;
}
/// A minimal template with a single hyphon as row-divider.
const Template minimal = const Template();
/// A minimal template with a single hyphon as row-divider.
const Template rows =
const Template(headerDivider: '=', rowDivider: "-", cellJoin: "-");
/// [Template] holds styling information for a table. Each parameter assumes
/// either the empty string or a single character. Let:
///
/// headerDivider - 'h'
/// columnDivider - 'i'
/// rowDivider - 'r'
/// topBottomFrame - '='
/// leftRightFrame - 'I'
/// rowJoin - 'R'
/// cornerJoin 'C'
/// cellJoin - 'X'
///
/// The below shows an example with the characters used
///
/// C===================================================C
/// I column 1 i column 2 i column 3 I
/// RhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhR
/// I row 1 i row 1 i row 1 I
/// RrrrrrrrrrrXrrrrrrrrrrXrrrrrrrrrrrrrrrrrrrrrrrrrrrrrR
/// I row 2 i row 2 i row 2 I
/// RrrrrrrrrrrXrrrrrrrrrrXrrrrrrrrrrrrrrrrrrrrrrrrrrrrrR
///
class Template {
final String headerDivider;
final String columnDivider;
final String rowDivider;
final String topBottomFrame;
final String leftRightFrame;
final String rowJoin;
final String cornerJoin;
final String cellJoin;
const Template(
{this.headerDivider = "-",
this.columnDivider = " ",
this.rowDivider = "",
this.topBottomFrame = "",
this.leftRightFrame = "",
this.cornerJoin = "",
this.rowJoin = "",
this.cellJoin = " "});
}