A small utility to print data in an ascii table
TextTable
public class TextTable {
private TextTable(){ }
public static List<String> getLines(List<TableRow> items){
return new TextTableFormatter(items).createTableRows();
}
public static void print(List<TableRow> items, PrintStream stream){
getLines(items).forEach(stream::println);
}
public static void print(List<TableRow> items){
print(items, System.out);
}
}
TableRow
@FunctionalInterface
public interface TableRow {
Map<String, String> getCellDescriptions();
}
TextTableFormatter
public class TextTableFormatter {
private final Map<String, Integer> columnsWidths;
private final Set<String> columnNames;
private final List<TableRow> items;
//see https://en.wikipedia.org/wiki/Box-drawing_character for more characters
private static final String CROSS = "┼";
private static final String HORIZONTAL_LINE = "─";
private static final String VERTICAL_LINE = "│";
private static final String CORNER_TOP_LEFT = "┌";
private static final String CORNER_TOP_RIGHT = "┐";
private static final String JOIN_TOP = "┬";
private static final String JOIN_LEFT = "├";
private static final String JOIN_RIGHT = "┤";
private static final String CORNER_BOTTOM_LEFT = "└";
private static final String CORNER_BOTTOM_RIGHT = "┙";
private static final String JOIN_BOTTOM = "┴";
TextTableFormatter(List<TableRow> items) {
this.items = items;
columnNames = calculateColumnNames();
columnsWidths = calculateColumnWidths();
}
List<String> createTableRows() {
List<String> tableRows = new ArrayList<>();
//header rows
tableRows.add(createTopTableLine());
tableRows.add(createHeaderRow());
tableRows.add(createMiddleTableLine());
//data rows
for (int i = 0; i < items.size(); i++) {
TableRow item = items.get(i);
tableRows.add(createDataRow(item));
tableRows.add(isLastItem(i) ? createBottomTableLine() : createMiddleTableLine());
}
return tableRows;
}
private String createDataRow(TableRow item) {
StringBuilder itemLine = new StringBuilder();
for (String columnName : columnNames) {
String cell = item.getCellDescriptions().getOrDefault(columnName, "");
itemLine.append(VERTICAL_LINE).append(fillCenter(cell, columnsWidths.get(columnName)));
}
itemLine.append(VERTICAL_LINE);
return itemLine.toString();
}
private String createHeaderRow() {
StringBuilder headerLine = new StringBuilder();
for (String columnName : columnNames) {
headerLine.append(VERTICAL_LINE).append(fillCenter(columnName, columnsWidths.get(columnName)));
}
headerLine.append(VERTICAL_LINE);
return headerLine.toString();
}
private Map<String, Integer> calculateColumnWidths() {
Map<String, Integer> columnWidths = new HashMap<>();
for (String columnName : columnNames) {
int width = items.stream()
.map(i -> i.getCellDescriptions().getOrDefault(columnName, ""))
.mapToInt(String::length)
.max().orElse(0);
width = Math.max(width, columnName.length());
columnWidths.put(columnName, width);
}
return columnWidths;
}
private Set<String> calculateColumnNames() {
return items.stream()
.flatMap(i -> i.getCellDescriptions().keySet().stream())
.collect(Collectors.toSet());
}
private String createTopTableLine() {
return createTableLine(CORNER_TOP_LEFT, JOIN_TOP, CORNER_TOP_RIGHT);
}
private String createMiddleTableLine() {
return createTableLine(JOIN_LEFT, CROSS, JOIN_RIGHT);
}
private String createBottomTableLine() {
return createTableLine(CORNER_BOTTOM_LEFT, JOIN_BOTTOM, CORNER_BOTTOM_RIGHT);
}
private String createTableLine(String startCharacter, String contentSeparator, String endCharacter) {
return startCharacter + columnNames.stream().map(c -> HORIZONTAL_LINE.repeat(columnsWidths.get(c))).collect(Collectors.joining(contentSeparator)) + endCharacter;
}
private String fillCenter(String input, int length) {
int numberOfMissing = length - input.length();
int lead = numberOfMissing / 2;
int trail = numberOfMissing - lead;
return " ".repeat(lead) + input + (" ".repeat(trail));
}
private boolean isLastItem(int i) {
return i == items.size()-1;
}
}
TextTableTest
class TextTableTest {
@Test
public void testTablePrintOutput(){
List<TableRow> items = List.of(
new TablePoint(1,2),
new TablePoint(1.2, 2.3),
new Person("peter", "parker"),
new Person("bruce", "waytoolongname"),
new NamedPoint(-2.44, 3.1415, "alfred"));
TextTable.print(items);
}
private record TablePoint (double x, double y) implements TableRow {
@Override
public Map<String, String> getCellDescriptions() {
Map<String,String> tableRow = new HashMap<>();
tableRow.put("x", Double.toString(x));
tableRow.put("y", Double.toString(y));
return tableRow;
}
}
private record Person (String name, String familyName) implements TableRow {
@Override
public Map<String, String> getCellDescriptions() {
Map<String,String> tableRow = new HashMap<>();
tableRow.put("first name", name);
tableRow.put("family", familyName);
return tableRow;
}
}
private record NamedPoint (double x, double y, String name) implements TableRow {
@Override
public Map<String, String> getCellDescriptions() {
Map<String,String> tableRow = new HashMap<>();
tableRow.put("x", Double.toString(x));
tableRow.put("y", Double.toString(y));
tableRow.put("first name", name);
return tableRow;
}
}
}
output testcase
┌──────────┬─────┬──────┬──────────────┐
│first name│ x │ y │ family │
├──────────┼─────┼──────┼──────────────┤
│ │ 1.0 │ 2.0 │ │
├──────────┼─────┼──────┼──────────────┤
│ │ 1.2 │ 2.3 │ │
├──────────┼─────┼──────┼──────────────┤
│ peter │ │ │ parker │
├──────────┼─────┼──────┼──────────────┤
│ bruce │ │ │waytoolongname│
├──────────┼─────┼──────┼──────────────┤
│ alfred │-2.44│3.1415│ │
└──────────┴─────┴──────┴──────────────┙
my challenges so far:
- i am having trouble with the
TableRowinterface, i am not sure if that would be the proper approach - i am also having trouble with my test cases, how am i supposed to test the output?
- open/closed, how easy could one extend the utility to provide more functionallity
(eg. sorted columns names, different fill-blanks-behaviour, or adding a number column) - how much sense makes the
TextTableclass? is this really helpful for simplified access (see tests) or just a middle man antipattern?
of course any feedback is welcome!
note:
this code is not yet optimized, since the amount of data should be read by humans