package org.mafagafogigante.dungeon.util;
import org.mafagafogigante.dungeon.game.ColoredString;
import org.mafagafogigante.dungeon.game.DungeonString;
import org.mafagafogigante.dungeon.game.Writable;
import org.mafagafogigante.dungeon.gui.GameWindow;
import org.mafagafogigante.dungeon.logging.DungeonLogger;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Table class that represents an arrangement of strings in rows and columns.
*
* <p>Only allows for data addition, you cannot query or update a Table in any way. This class is used for data
* visualization, not organization or storage.
*/
public class Table extends Writable {
private static final char HORIZONTAL_BAR = '-';
private static final String VERTICAL_BAR = "|";
private static final int MAXIMUM_COLUMNS = 6;
private static final int MINIMUM_WIDTH = 5; // Must be positive.
/**
* The content of the Table.
*/
private final List<Column> columns = new ArrayList<>();
/**
* A CounterMap of Integers representing how many horizontal separators should precede each row.
*/
private CounterMap<Integer> separators;
/**
* Constructs a Table using the provided Strings as column headers.
*
* <strong>There is a hard limit of six columns</strong> in order to prevent huge wide tables that wouldn't ever fit
* the screen.
*
* @param headers the headers, not empty, at most six values
*/
public Table(String... headers) {
if (headers.length == 0) {
throw new IllegalArgumentException("tried to create Table with no headers.");
} else if (headers.length > MAXIMUM_COLUMNS) {
throw new IllegalArgumentException("tried to create Table with more than " + MAXIMUM_COLUMNS + " headers.");
}
for (String header : headers) {
columns.add(new Column(header));
}
}
/**
* Creates a string of repeated characters.
*/
private static String makeRepeatedCharacterString(int repetitions, char character) {
StringBuilder builder = new StringBuilder(repetitions);
for (int i = 0; i < repetitions; i++) {
builder.append(character);
}
return builder.toString();
}
/**
* Appends a row to a DungeonString.
*
* @param builder the DungeonString object
* @param widths the widths of the columns of the table
* @param values the values of the row
*/
private static void appendRow(DungeonString builder, int[] widths, String... values) {
for (int i = 0; i < values.length; i++) {
int columnWidth = widths[i];
String currentValue = values[i];
if (currentValue.length() > columnWidth) {
if (columnWidth < 4) { // This is how spreadsheet editors seem to handle it.
builder.append(makeRepeatedCharacterString(columnWidth, '#'));
} else {
builder.append(currentValue.substring(0, columnWidth - 3));
builder.append("...");
}
} else {
builder.append(currentValue);
int extraSpaces = columnWidth - currentValue.length();
builder.append(makeRepeatedCharacterString(extraSpaces, ' '));
}
if (i < values.length - 1) {
builder.append(VERTICAL_BAR);
}
}
builder.append("\n");
}
/**
* Append a horizontal separator made up of dashes to a DungeonString.
*
* @param builder the DungeonString object
* @param columnWidths the width of the columns of the table
*/
private static void appendHorizontalSeparator(DungeonString builder, int[] columnWidths, int columnCount) {
String[] pseudoRow = new String[columnCount];
for (int i = 0; i < columnWidths.length; i++) {
pseudoRow[i] = makeRepeatedCharacterString(columnWidths[i], HORIZONTAL_BAR);
}
appendRow(builder, columnWidths, pseudoRow);
}
/**
* Distributes a value among buckets. For instance, distributing 3 over {2, 3, 4} gives {3, 4, 5} and distributing -8
* over {5, 10} gives {1, 6}. If the division of value by the size of buckets is not exact, the first buckets are
* going to get more modified. For instance, distributing 3 over {2, 3} gives {4, 4} and distributing -8 over {5, 10,
* 15} gives {2, 7, 13}.
*
* <p>This algorithm respects the MINIMUM_WIDTH constant.
*
* <p>The time complexity of this implementation is O(n) on the size of buckets
*
* @param value the total to be distributed
* @param buckets the buckets, not empty, not null
*/
private static void distribute(int value, @NotNull int[] buckets) {
repeatModification(Math.abs(value), Integer.signum(value), buckets);
}
/**
* Applies modification reps times over the bucket array.
*/
private static void repeatModification(int reps, int modification, @NotNull int[] buckets) {
if (buckets.length == 0) {
throw new IllegalArgumentException("buckets must have at least one element.");
}
if (DungeonMath.sum(buckets) + reps * modification < MINIMUM_WIDTH * buckets.length) {
String format = "minimum is impossible. Got %d x %d for %s for a minimum of %d.";
String message = String.format(format, reps, modification, Arrays.toString(buckets), MINIMUM_WIDTH);
throw new IllegalArgumentException(message);
}
int i = 0;
while (reps > 0) {
if (buckets[i] + modification >= MINIMUM_WIDTH) {
buckets[i] += modification;
reps--;
}
i = (i + 1) % buckets.length;
}
}
/**
* Inserts a row of values at the end of the table. The number of provided values should equal the number of columns.
*
* @param values the values to be inserted
*/
public void insertRow(String... values) {
if (values.length != columns.size()) {
String expectedButGotString = "Expected " + columns.size() + ", but got " + values.length + ".";
if (values.length < columns.size()) {
throw new IllegalArgumentException("provided less values than there are rows. " + expectedButGotString);
} else if (values.length > columns.size()) {
throw new IllegalArgumentException("provided more values than there are rows. " + expectedButGotString);
}
}
for (int i = 0; i < values.length; i++) {
columns.get(i).insertValue(values[i]);
}
}
/**
* Inserts a horizontal separator at the last row of the Table.
*/
public void insertSeparator() {
if (separators == null) {
separators = new CounterMap<>();
}
separators.incrementCounter(columns.get(0).rows.size());
}
private int[] calculateColumnWidths() throws IllegalArgumentException {
int[] widths = getMaximumColumnWidths();
int availableWidth = getAvailableWidth();
int difference = availableWidth - DungeonMath.sum(widths);
distribute(difference, widths);
return widths;
}
private int getAvailableWidth() {
// Subtract the number of columns to account for separators. Add one because there is not a separator at the end.
return GameWindow.getColumns() - columns.size() + 1;
}
private int[] getMaximumColumnWidths() {
int[] widths = new int[columns.size()];
for (int i = 0; i < widths.length; i++) {
widths[i] = columns.get(i).widestValue;
}
return widths;
}
@Override
public List<ColoredString> toColoredStringList() {
DungeonString string = new DungeonString();
int columnCount = columns.size();
int[] columnWidths;
try {
// You likely don't want toColoredStringList to be throwing exceptions, so catch them early.
columnWidths = calculateColumnWidths();
} catch (RuntimeException log) {
DungeonLogger.warning(log.getMessage());
string.append("Failed to generate a visual representation of the table.");
return string.toColoredStringList();
}
String[] currentRow = new String[columnCount];
// Insert headers
for (int i = 0; i < columnCount; i++) {
currentRow[i] = columns.get(i).header;
}
appendRow(string, columnWidths, currentRow);
// A horizontal separator.
appendHorizontalSeparator(string, columnWidths, columnCount);
int rowCount = columns.get(0).rows.size();
// Insert table body.
for (int rowIndex = 0; rowIndex < rowCount + 1; rowIndex++) {
if (separators != null) {
for (int remaining = separators.getCounter(rowIndex); remaining > 0; remaining--) {
appendHorizontalSeparator(string, columnWidths, columnCount);
}
}
if (rowIndex != rowCount) {
for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) {
currentRow[columnIndex] = columns.get(columnIndex).rows.get(rowIndex);
}
appendRow(string, columnWidths, currentRow);
}
}
return string.toColoredStringList();
}
private static class Column {
final String header;
final List<String> rows = new ArrayList<>();
int widestValue;
public Column(String header) {
this.header = header;
widestValue = header.length();
}
/**
* Inserts a new value at the end of this Column. If the provided value is null, an empty string is used.
*
* @param value the value to be inserted, null will be replaced by an empty string
*/
void insertValue(String value) {
if (value == null) {
value = "";
}
rows.add(value);
widestValue = Math.max(widestValue, value.length());
}
}
}