/*
* Copyright 2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.table;
import static org.springframework.shell.table.BorderSpecification.NONE;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.shell.TerminalSizeAware;
/**
* This is the central API for table rendering. A Table object is constructed with a given
* TableModel, which holds raw table contents. Its rendering logic is then altered by applying
* various customizations, in a fashion very similar to what is used <i>e.g.</i> in a spreadsheet
* program:<ol>
* <li>{@link #format(CellMatcher, Formatter) formatters} know how to derive character data out of raw data. For
* example, numbers are
* formatted according to a Locale, or Maps are emitted as a series of {@literal key=value} lines</li>
* <li>{@link #size(CellMatcher, SizeConstraints) size constraints} are then applied, which decide how
* much column real estate to allocate to cells</li>
* <li>{@link #wrap(CellMatcher, TextWrapper) text wrapping policies} are applied once the column sizes
* are known</li>
* <li>finally, {@link #align(CellMatcher, Aligner) alignment} strategies actually render
* text as a series of space-padded strings that draw nicely on screen.</li>
* </ol>
* All those customizations are applied selectively on the Table cells thanks to a {@link CellMatcher}: One can
* decide to right pad column number 3, or to format in a certain way all instances of {@literal java.util.Map}.
*
* <p>Of course, all of those customizations often work hand in hand, and not all combinations make sense:
* one needs to anticipate the fact that text will be split using the ' ' (space) character to properly
* calculate column sizes.</p>
* @author Eric Bottard
*/
public class Table implements TerminalSizeAware {
private final int rows;
private final int columns;
private TableModel model;
private Map<CellMatcher, Formatter> formatters = new LinkedHashMap<CellMatcher, Formatter>();
private Map<CellMatcher, SizeConstraints> sizeConstraints = new LinkedHashMap<CellMatcher, SizeConstraints>();
private Map<CellMatcher, TextWrapper> wrappers = new LinkedHashMap<CellMatcher, TextWrapper>();
private Map<CellMatcher, Aligner> aligners = new LinkedHashMap<CellMatcher, Aligner>();
private List<BorderSpecification> borderSpecifications = new ArrayList<BorderSpecification>();
/**
* Construct a new Table with the given model and customizers.
* The passed in LinkedHashMap should be in reverse-insertion order (<i>i.e.</i> the first CellMatcher
* found in iteration order will "win").
*
* @see TableBuilder#build()
*/
/*package*/ Table(TableModel model,
LinkedHashMap<CellMatcher, Formatter> formatters,
LinkedHashMap<CellMatcher, SizeConstraints> sizeConstraints,
LinkedHashMap<CellMatcher, TextWrapper> wrappers,
LinkedHashMap<CellMatcher, Aligner> aligners,
List<BorderSpecification> borderSpecifications) {
this.model = model;
this.formatters = formatters;
this.sizeConstraints = sizeConstraints;
this.wrappers = wrappers;
this.aligners = aligners;
this.borderSpecifications = borderSpecifications;
rows = model.getRowCount();
columns = model.getColumnCount();
}
public TableModel getModel() {
return model;
}
public String render(int totalAvailableWidth) {
StringBuilder result = new StringBuilder();
int[] cellHeights = new int[rows];
int[] cellWidths;
int[] minCellWidths = new int[columns];
int[] maxCellWidths = new int[columns];
String[][][] subLines = new String[rows][columns][];
Borders borders = new Borders();
int widthAvailableForContents = totalAvailableWidth - borders.getNumberOfVerticalBorders();
// First, compute desired column widths
for (int row = 0; row < rows; row++) {
for (int column = 0; column < columns; column++) {
Object value = model.getValue(row, column);
String[] lines = getFormatter(row, column).format(value);
subLines[row][column] = lines;
SizeConstraints.Extent extent = getSizeConstraints(row, column).width(lines, widthAvailableForContents, columns);
minCellWidths[column] = Math.max(minCellWidths[column], extent.min);
maxCellWidths[column] = Math.max(maxCellWidths[column], extent.max);
}
}
cellWidths = computeColumnWidths(widthAvailableForContents, minCellWidths, maxCellWidths);
// Now that widths are known, apply wrapping & render
for (int row = 0; row < rows; row++) {
for (int column = 0; column < columns; column++) {
subLines[row][column] = getWrapper(row, column).wrap(subLines[row][column], cellWidths[column]);
cellHeights[row] = Math.max(cellHeights[row], subLines[row][column].length);
}
for (int column = 0; column < columns; column++) {
for (Map.Entry<CellMatcher, Aligner> kv : aligners.entrySet()) {
if (kv.getKey().matches(row, column, model)) {
subLines[row][column] = kv.getValue().align(subLines[row][column], cellWidths[column], cellHeights[row]);
}
}
}
}
for (int row = 0; row < rows; row++) {
// TOP CELL BORDER
int before = result.length();
for (int column = 0; column < columns; column++) {
borders.paintCorner(row, column, result);
borders.paintHorizontal(row, column, cellWidths[column], result);
}
borders.paintCorner(row, columns, result);
if (result.length() > before) {
result.append('\n');
}
for (int subRow = 0; subRow < cellHeights[row]; subRow++) {
for (int column = 0; column < columns; column++) {
// LEFT CELL BORDER
borders.paintVertical(row, column, result);
String[] lines = subLines[row][column];
result.append(lines[subRow]);
}
// TABLE RIGHT BORDER
borders.paintVertical(row, columns, result);
result.append("\n");
}
}
// TABLE BOTTOM BORDER
int before = result.length();
for (int column = 0; column < columns; column++) {
borders.paintCorner(rows, column, result);
borders.paintHorizontal(rows, column, cellWidths[column], result);
}
// TABLE BOTTOM RIGHT CORNER
borders.paintCorner(rows, columns, result);
if (result.length() > before) {
result.append('\n');
}
return result.toString();
}
private int[] computeColumnWidths(int availableWidth, int[] minCellWidths, int[] maxCellWidths) {
int[] cellWidths;
int minTableWidth = 0, maxTableWidth = 0;
for (int column = 0; column < columns; column++) {
minTableWidth += minCellWidths[column];
maxTableWidth += maxCellWidths[column];
}
// Can use max desired width
if (maxTableWidth <= availableWidth) {
cellWidths = maxCellWidths;
} // will overflow
else if (minTableWidth >= availableWidth) {
cellWidths = minCellWidths;
} // Redistribute nicely
else {
int W = availableWidth - minTableWidth;
int D = maxTableWidth - minTableWidth;
cellWidths = new int[columns];
for (int column = 0; column < columns; column++) {
cellWidths[column] = minCellWidths[column] + W * (maxCellWidths[column] - minCellWidths[column]) / D;
}
}
return cellWidths;
}
private TextWrapper getWrapper(int row, int column) {
for (Map.Entry<CellMatcher, TextWrapper> kv : wrappers.entrySet()) {
if (kv.getKey().matches(row, column, model)) {
return kv.getValue();
}
}
throw new AssertionError("Can't be reached thanks to the whole-table default");
}
private SizeConstraints getSizeConstraints(int row, int column) {
for (Map.Entry<CellMatcher, SizeConstraints> kv : sizeConstraints.entrySet()) {
if (kv.getKey().matches(row, column, model)) {
return kv.getValue();
}
}
throw new AssertionError("Can't be reached thanks to the whole-table default");
}
private Formatter getFormatter(int row, int column) {
for (Map.Entry<CellMatcher, Formatter> kv : formatters.entrySet()) {
if (kv.getKey().matches(row, column, model)) {
return kv.getValue();
}
}
throw new AssertionError("Can't be reached thanks to the whole-table default");
}
/**
* An instance of this class knows where to paint border glyphs.
*
* <p>In all instance arrays, 'row' and 'column' are actually indices in-between
* table rows and columns. Hence, sizes are larger by one.</p>
* @author Eric Bottard
*/
private class Borders {
/**
* Glyph to paint a vertical line at row,col.
*/
private char[][] verticals;
/**
* Glyph to paint a horizontal line at row,col.
*/
private char[][] horizontals;
/**
* The type of corner, if any, to paint at row,col.
*/
private char[][] corners;
/**
* True if at least one vertical bar exists in that col.
*/
private boolean[] vFillers;
/**
* True if at least one horizontal bar exists in that row.
*/
private boolean[] hFillers;
public Borders() {
verticals = new char[rows][columns + 1];
horizontals = new char[rows + 1][columns];
corners = new char[rows + 1][columns + 1];
vFillers = new boolean[columns + 1];
hFillers = new boolean[rows + 1];
init();
}
private void init() {
for (int row = 0; row <= rows; row++) {
for (int column = 0; column <= columns; column++) {
for (BorderSpecification bs : borderSpecifications) {
if (row < rows) {
char verticalThere = bs.verticals(row, column);
if (verticalThere != BorderStyle.NONE) {
this.verticals[row][column] = verticalThere;
vFillers[column] |= true;
}
}
if (column < columns) {
char horizontalThere = bs.horizontals(row, column);
if (horizontalThere != BorderStyle.NONE) {
this.horizontals[row][column] = horizontalThere;
hFillers[row] |= true;
}
}
}
}
}
// Compute corners when horizontals & verticals intersect
for (int row = 0; row <= rows; row++) {
for (int column = 0; column <= columns; column++) {
char left = (column - 1 >= 0) ? horizontals[row][column - 1] : NONE;
char right = (column < columns) ? horizontals[row][column] : NONE;
char above = (row - 1 >= 0) ? verticals[row - 1][column] : NONE;
char below = (row < rows) ? verticals[row][column] : NONE;
corners[row][column] = BorderStyle.intersection(above, below, left, right);
}
}
}
private void paintCorner(int row, int column, StringBuilder stringBuilder) {
if (corners[row][column] != NONE) {
stringBuilder.append(corners[row][column]);
} // If there is a border in same row|column, paint filler
else if (vFillers[column] && hFillers[row]) {
stringBuilder.append(' ');
}
}
private void paintVertical(int row, int column, StringBuilder stringBuilder) {
if (verticals[row][column] != NONE) {
stringBuilder.append(verticals[row][column]);
}
else if (vFillers[column]) {
stringBuilder.append(' ');
}
}
private void paintHorizontal(int row, int column, int width, StringBuilder stringBuilder) {
if (horizontals[row][column] != NONE) {
for (int i = 0; i < width; i++) {
stringBuilder.append(horizontals[row][column]);
}
}
else if (hFillers[row]) {
for (int i = 0; i < width; i++) {
stringBuilder.append(' ');
}
}
}
/**
* Return the number of vertical borders, and hence the space consumed by those.
*/
public int getNumberOfVerticalBorders() {
int result = 0;
for (boolean b : vFillers) {
if (b) {
result++;
}
}
return result;
}
}
}