TableData.java

package swingtree.api.model;

import org.jspecify.annotations.Nullable;
import sprouts.Tuple;
import swingtree.UI;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/**
 *  An immutable value describing the entire contents of a table: its cells, the
 *  name and class of every column, and the {@link UI.ListData} layout tying the
 *  two together. Hold one of these in a {@link sprouts.Var} property, bind that
 *  property to a table, and you have a complete, thread safe, reactive table:
 *  <pre>{@code
 *  Var<TableData> data = Var.of(
 *          TableData.of(UI.ListData.ROW_MAJOR, "Name", "Age")
 *              .addRow("Alice", 30)
 *              .addRow("Bob",   42)
 *      );
 *
 *  UI.table(data);
 *  }</pre>
 *  Changing the table is then simply a matter of changing the value in the property,
 *  from wherever your business logic happens to live:
 *  <pre>{@code
 *  data.update( it -> it.addRow("Carol", 55) );
 *  }</pre>
 *
 *  <h2>It is a value</h2>
 *
 *  Every method on this class which sounds like it changes something really returns
 *  a <i>new</i> {@link TableData}, leaving the one you called it on untouched. Two
 *  tables with the same layout, columns and cells are {@link #equals(Object)} to each
 *  other and have the same {@link #hashCode()}, which is what lets a table tell
 *  whether anything actually changed.
 *  <p>
 *  Do not let the copying scare you: the cells live in immutable {@link Tuple}s,
 *  which share their structure between versions. Adding a row to a thousand row
 *  table does not copy a thousand rows.
 *
 *  <h2>It is thread safe</h2>
 *
 *  Because this is a deeply immutable value, the application thread and the UI
 *  thread (the AWT Event Dispatch Thread) can hand it to each other without any
 *  locking. Under {@link swingtree.threading.EventProcessor#DECOUPLED}, the value
 *  in your property <i>is</i> what the UI thread reads, so every read within a
 *  single UI thread task sees the same consistent table, even while your
 *  application thread is busy building the next version of it.
 *
 *  <h2>It is cheap to update</h2>
 *
 *  A {@link Tuple} remembers how it was derived from its predecessor, and a
 *  {@link UI.ListData#ROW_MAJOR} table passes that knowledge straight to the
 *  {@link javax.swing.JTable}: adding, removing or changing a handful of rows only
 *  repaints those rows, rather than rebuilding the whole table. This is why a row
 *  major layout is the right default for large, frequently changing tables.
 *
 *  <h2>Rows and columns, whatever the layout</h2>
 *
 *  The {@link #layout()} decides how the {@link #cells()} are <i>stored</i>: in a
 *  {@link UI.ListData#ROW_MAJOR} table the outer {@link Tuple} holds the rows and
 *  each inner {@link Tuple} holds the cells of one row, whereas in a
 *  {@link UI.ListData#COLUMN_MAJOR} table the outer {@link Tuple} holds the columns.
 *  That is a storage detail: every method on this class speaks in {@code (row, column)}
 *  terms no matter which layout you picked, so {@link #addRow(Object...)} adds a row
 *  to a column major table just as happily (it is merely a little more work for it).
 */
public final class TableData
{
    @SuppressWarnings("unchecked")
    private static final Class<Tuple<@Nullable Object>> LINE_TYPE = (Class<Tuple<@Nullable Object>>)(Class<?>) Tuple.class;
    @SuppressWarnings("unchecked")
    private static final Class<Class<?>> CLASS_TYPE = (Class<Class<?>>)(Class<?>) Class.class;

    private static final Tuple<@Nullable Object>        EMPTY_LINE    = Tuple.ofNullable(Object.class);
    private static final Tuple<Tuple<@Nullable Object>> EMPTY_LINES   = Tuple.of(LINE_TYPE);
    private static final Tuple<@Nullable String>        EMPTY_NAMES   = Tuple.ofNullable(String.class);
    private static final Tuple<Class<?>>                EMPTY_CLASSES = Tuple.of(CLASS_TYPE);

    private static final TableData EMPTY = new TableData(
            UI.ListData.ROW_MAJOR, EMPTY_NAMES, EMPTY_CLASSES, EMPTY_LINES
        );

    /**
     *  The shared empty table: no columns, no rows, not editable.
     *  A good starting point for a table you are about to build up.
     *  @return An empty {@link TableData}.
     */
    public static TableData empty() {
        return EMPTY;
    }

    /**
     *  Creates a table which has columns but no rows yet, which is how most tables
     *  start out in life:
     *  <pre>{@code
     *  TableData.of(UI.ListData.ROW_MAJOR, "Name", "Age", "City")
     *  }</pre>
     *  The columns are all of type {@link Object} until you say otherwise through
     *  {@link #setColumnClassAt(int, Class)}.
     *
     * @param layout      The {@link UI.ListData} layout of the table, which also decides
     *                    whether the user may edit its cells ({@code *_EDITABLE}).
     * @param columnNames The names of the columns, one per column.
     * @return A new {@link TableData} with the given columns and no rows.
     */
    public static TableData of( UI.ListData layout, String... columnNames ) {
        Objects.requireNonNull(layout);
        Objects.requireNonNull(columnNames);
        List<Class<?>> classes = new ArrayList<>(columnNames.length);
        for ( int i = 0; i < columnNames.length; i++ )
            classes.add(Object.class);
        return new TableData(
                    layout,
                    Tuple.ofNullable(String.class, columnNames),
                    Tuple.of(CLASS_TYPE, classes),
                    EMPTY_LINES
                );
    }

    /**
     *  Creates a table from nothing but its cells, whose dimensions are derived from
     *  them: the major axis of the {@code layout} is as long as the outer {@link Tuple},
     *  and the minor axis is as long as the longest inner {@link Tuple} (so a ragged
     *  matrix reads as though it were padded with {@code null} cells).
     *  The columns have no names, which makes a table fall back to the default
     *  (spreadsheet style) names, and they are all of type {@link Object}.
     *
     * @param layout The {@link UI.ListData} layout describing how {@code cells} is to be interpreted.
     * @param cells  The cell values, an immutable tuple of rows (or columns, see {@code layout}),
     *               each of which is an immutable tuple of cell values.
     * @return A new {@link TableData}.
     */
    public static TableData of(
        UI.ListData                    layout,
        Tuple<Tuple<@Nullable Object>> cells
    ) {
        Objects.requireNonNull(layout);
        Objects.requireNonNull(cells);
        return new TableData(layout, EMPTY_NAMES, EMPTY_CLASSES, cells);
    }

    /**
     *  Creates a fully described table, whose columns carry both a name and a class.
     *  <p>
     *  Note that the dimensions of the table are derived from what you pass here, and
     *  that the column metadata takes part in that: a table with three column names but
     *  rows only two cells wide still has three columns, the last of which reads as
     *  {@code null}. This is what lets a table have columns but no rows.
     *
     * @param layout        The {@link UI.ListData} layout describing how {@code cells} is to be interpreted.
     * @param columnNames   The column names, one per column, where {@code null} means
     *                      "fall back to the default (spreadsheet style) name".
     * @param columnClasses The column classes, one per column, which a {@link javax.swing.JTable}
     *                      consults to pick a renderer and an editor.
     * @param cells         The cell values, an immutable tuple of rows (or columns, see {@code layout}),
     *                      each of which is an immutable tuple of cell values.
     * @return A new {@link TableData}.
     */
    public static TableData of(
        UI.ListData                    layout,
        Tuple<@Nullable String>        columnNames,
        Tuple<Class<?>>                columnClasses,
        Tuple<Tuple<@Nullable Object>> cells
    ) {
        return new TableData(layout, columnNames, columnClasses, cells);
    }

    /**
     *  Builds a single row (or column) out of plain values, which is handy when you
     *  want to hand a prepared row to {@link #addRow(Tuple)} or {@link #setRowsAt(int, Tuple)},
     *  or a prepared column to {@link #addColumn(String, Class, Tuple)}.
     *
     * @param values The cell values of the row.
     * @return An immutable tuple of the given values.
     */
    public static Tuple<@Nullable Object> row( @Nullable Object... values ) {
        Objects.requireNonNull(values);
        return Tuple.ofNullable(Object.class, values);
    }

    private final UI.ListData                    _layout;
    private final Tuple<@Nullable String>        _columnNames;
    private final Tuple<Class<?>>                _columnClasses;
    private final Tuple<Tuple<@Nullable Object>> _cells;
    /*
        The two dimensions are not state of their own, they are a function of the
        three tuples above, precomputed here because a JTable asks for them
        relentlessly while it paints. Deriving them (instead of letting the caller
        supply them) is what keeps a table which grew a row or lost a column
        consistent without anybody having to keep a counter in sync.
     */
    private final int _rowCount;
    private final int _columnCount;

    private TableData(
        UI.ListData                    layout,
        Tuple<@Nullable String>        columnNames,
        Tuple<Class<?>>                columnClasses,
        Tuple<Tuple<@Nullable Object>> cells
    ) {
        _layout        = Objects.requireNonNull(layout);
        _columnNames   = Objects.requireNonNull(columnNames);
        _columnClasses = Objects.requireNonNull(columnClasses);
        _cells         = Objects.requireNonNull(cells);

        int longestLine = 0;
        for ( Tuple<@Nullable Object> line : cells )
            longestLine = Math.max(longestLine, line.size());
        int describedColumns = Math.max(columnNames.size(), columnClasses.size());

        if ( layout.isRowMajor() ) {
            _rowCount    = cells.size();
            _columnCount = Math.max(describedColumns, longestLine);
        } else {
            _rowCount    = longestLine;
            _columnCount = Math.max(describedColumns, cells.size());
        }
    }

    // ---- Reading ----

    /**
     *  The layout deciding how the {@link #cells()} of this table are stored, and
     *  whether the user may edit them.
     *  @return The {@link UI.ListData} layout of this table.
     */
    public UI.ListData layout() {
        return _layout;
    }

    /**
     *  Tells whether the user is allowed to edit the cells of this table, which is
     *  the case for the {@code *_EDITABLE} layouts. Note that a table also needs to
     *  live in a mutable {@link sprouts.Var} before an edit has anywhere to go.
     *  @return True if the layout of this table permits cell editing.
     */
    public boolean isEditable() {
        return _layout.isEditable();
    }

    /** Returns the number of rows in this table. @return The row count. */
    public int getRowCount() {
        return _rowCount;
    }

    /** Returns the number of columns in this table. @return The column count. */
    public int getColumnCount() {
        return _columnCount;
    }

    /**
     *  Tells whether this table has nothing to show, meaning it has no rows,
     *  no columns, or neither.
     *  @return True if this table has no cells to display.
     */
    public boolean isEmpty() {
        return _rowCount == 0 || _columnCount == 0;
    }

    /**
     *  Reads the value of a single cell, always in {@code (row, column)} terms,
     *  irrespective of the {@link #layout()} of this table.
     * @param rowIndex    The row index of the cell.
     * @param columnIndex The column index of the cell.
     * @return The value of the cell, or {@code null} if the indices are out of bounds.
     */
    public @Nullable Object getValueAt( int rowIndex, int columnIndex ) {
        if ( rowIndex < 0 || rowIndex >= _rowCount || columnIndex < 0 || columnIndex >= _columnCount )
            return null;
        return _cellAt(_majorOf(rowIndex, columnIndex), _minorOf(rowIndex, columnIndex));
    }

    /**
     *  Reads an entire row, padded with {@code null} to the width of the table, so
     *  that the returned tuple always has exactly {@link #getColumnCount()} entries.
     * @param rowIndex The index of the row.
     * @return The cells of the row, or an empty tuple if the index is out of bounds.
     */
    public Tuple<@Nullable Object> getRow( int rowIndex ) {
        if ( rowIndex < 0 || rowIndex >= _rowCount )
            return EMPTY_LINE;
        if ( _layout.isRowMajor() )
            return _padTo(_lineAt(rowIndex), _columnCount);
        return _gatherAcrossLines(rowIndex, _columnCount);
    }

    /**
     *  Reads an entire column, padded with {@code null} to the height of the table, so
     *  that the returned tuple always has exactly {@link #getRowCount()} entries.
     * @param columnIndex The index of the column.
     * @return The cells of the column, or an empty tuple if the index is out of bounds.
     */
    public Tuple<@Nullable Object> getColumn( int columnIndex ) {
        if ( columnIndex < 0 || columnIndex >= _columnCount )
            return EMPTY_LINE;
        if ( !_layout.isRowMajor() )
            return _padTo(_lineAt(columnIndex), _rowCount);
        return _gatherAcrossLines(columnIndex, _rowCount);
    }

    /**
     *  Reads the name of a column.
     * @param columnIndex The column index.
     * @return The column name, or {@code null} if the column has no name of its own
     *         (in which case a table falls back to the default, spreadsheet style name).
     */
    public @Nullable String getColumnName( int columnIndex ) {
        if ( columnIndex < 0 || columnIndex >= _columnNames.size() )
            return null;
        return _columnNames.get(columnIndex);
    }

    /**
     *  Reads the class of a column, which a {@link javax.swing.JTable} consults to
     *  pick a renderer and an editor for it.
     * @param columnIndex The column index.
     * @return The column class, or {@link Object} if the column has no class of its own.
     */
    public Class<?> getColumnClass( int columnIndex ) {
        if ( columnIndex < 0 || columnIndex >= _columnClasses.size() )
            return Object.class;
        return _columnClasses.get(columnIndex);
    }

    /**
     *  Looks up a column by name, so that your business logic may address columns
     *  by what they mean rather than by where they happen to sit:
     *  <pre>{@code
     *  data.setCellAt(0, data.indexOfColumn("Age"), 31);
     *  }</pre>
     * @param columnName The name of the column to look for.
     * @return The index of the first column with the given name, or {@code -1} if there is none.
     */
    public int indexOfColumn( @Nullable String columnName ) {
        for ( int i = 0; i < _columnNames.size(); i++ )
            if ( Objects.equals(_columnNames.get(i), columnName) )
                return i;
        return -1;
    }

    /**
     *  The raw cell values of this table, in the order described by its {@link #layout()}.
     *  This is exposed so that a table model can reuse the very same immutable
     *  structure without copying it, and so that you may reach for the full
     *  {@link Tuple} API when this class does not have the operation you need.
     *  @return The immutable tuple of rows (or columns, see {@link #layout()}).
     */
    public Tuple<Tuple<@Nullable Object>> cells() {
        return _cells;
    }

    /**
     *  The column names of this table, where a {@code null} entry means that
     *  the table falls back to the default (spreadsheet style) column name.
     *  @return The immutable tuple of column names.
     */
    public Tuple<@Nullable String> columnNames() {
        return _columnNames;
    }

    /**
     *  The column classes of this table, which a {@link javax.swing.JTable}
     *  consults to pick a renderer and editor for each column.
     *  @return The immutable tuple of column classes.
     */
    public Tuple<Class<?>> columnClasses() {
        return _columnClasses;
    }

    // ---- Changing cells ----

    /**
     *  Produces a new table in which a single cell has a new value, reusing the
     *  immutable structure of this table for everything else.
     *  <p>
     *  Note that this is a targeted change: a row major table hands it to a
     *  {@link javax.swing.JTable} as a single row update, so only that row repaints.
     *
     * @param rowIndex    The row index of the cell to change.
     * @param columnIndex The column index of the cell to change.
     * @param value       The new value of the cell.
     * @return A new {@link TableData} with the changed cell, or this table unchanged
     *         if the indices are out of bounds or the cell already holds that value.
     */
    public TableData setCellAt( int rowIndex, int columnIndex, @Nullable Object value ) {
        if ( rowIndex < 0 || rowIndex >= _rowCount || columnIndex < 0 || columnIndex >= _columnCount )
            return this;
        int majorIndex = _majorOf(rowIndex, columnIndex);
        int minorIndex = _minorOf(rowIndex, columnIndex);
        if ( Objects.equals(_cellAt(majorIndex, minorIndex), value) )
            return this;
        Tuple<@Nullable Object> line = _padTo(_lineAt(majorIndex), minorIndex + 1).setAt(minorIndex, value);
        return _withCells(_padLines(majorIndex + 1).setAt(majorIndex, line));
    }

    // ---- Changing rows ----

    /**
     *  Appends a row to the end of this table, built from plain values:
     *  <pre>{@code
     *  data.addRow("Alice", 30, "Rome")
     *  }</pre>
     * @param values The cell values of the new row, one per column.
     * @return A new {@link TableData} with the additional row.
     */
    public TableData addRow( @Nullable Object... values ) {
        return addRowAt(_rowCount, row(values));
    }

    /**
     *  Appends a row to the end of this table.
     * @param row The cells of the new row, one per column.
     * @return A new {@link TableData} with the additional row.
     */
    public TableData addRow( Tuple<@Nullable Object> row ) {
        return addRowAt(_rowCount, row);
    }

    /**
     *  Inserts a row at the given index, built from plain values.
     * @param rowIndex The index the new row will have.
     * @param values   The cell values of the new row, one per column.
     * @return A new {@link TableData} with the additional row.
     */
    public TableData addRowAt( int rowIndex, @Nullable Object... values ) {
        return addRowAt(rowIndex, row(values));
    }

    /**
     *  Inserts a row at the given index.
     * @param rowIndex The index the new row will have.
     * @param row      The cells of the new row, one per column.
     * @return A new {@link TableData} with the additional row, or this table unchanged
     *         if the index is out of bounds.
     */
    public TableData addRowAt( int rowIndex, Tuple<@Nullable Object> row ) {
        Objects.requireNonNull(row);
        return addRowsAt(rowIndex, Tuple.of(LINE_TYPE, row));
    }

    /**
     *  Appends several rows at once, which is the efficient way to grow a table by
     *  more than one row: a row major table hands this to a {@link javax.swing.JTable}
     *  as a <i>single</i> insertion of a range of rows, rather than one event per row.
     * @param rows The new rows, each holding one cell value per column.
     * @return A new {@link TableData} with the additional rows.
     */
    public TableData addRows( Tuple<Tuple<@Nullable Object>> rows ) {
        return addRowsAt(_rowCount, rows);
    }

    /**
     *  Inserts several rows at once, starting at the given index.
     *  See {@link #addRows(Tuple)} for why you should prefer this over adding
     *  the rows one at a time.
     * @param rowIndex The index the first of the new rows will have.
     * @param rows     The new rows, each holding one cell value per column.
     * @return A new {@link TableData} with the additional rows, or this table unchanged
     *         if the index is out of bounds or there is nothing to add.
     */
    public TableData addRowsAt( int rowIndex, Tuple<Tuple<@Nullable Object>> rows ) {
        Objects.requireNonNull(rows);
        if ( rowIndex < 0 || rowIndex > _rowCount || rows.isEmpty() )
            return this;
        if ( _layout.isRowMajor() )
            return _withCells(_cells.addAllAt(rowIndex, rows));
        return _withCells(_insertMinors(rowIndex, rows, _rowCount));
    }

    /**
     *  Replaces the row at the given index, built from plain values.
     * @param rowIndex The index of the row to replace.
     * @param values   The new cell values of the row, one per column.
     * @return A new {@link TableData} with the replaced row.
     */
    public TableData setRowAt( int rowIndex, @Nullable Object... values ) {
        return setRowAt(rowIndex, row(values));
    }

    /**
     *  Replaces the row at the given index.
     * @param rowIndex The index of the row to replace.
     * @param row      The new cells of the row, one per column.
     * @return A new {@link TableData} with the replaced row, or this table unchanged
     *         if the index is out of bounds.
     */
    public TableData setRowAt( int rowIndex, Tuple<@Nullable Object> row ) {
        Objects.requireNonNull(row);
        return setRowsAt(rowIndex, Tuple.of(LINE_TYPE, row));
    }

    /**
     *  Replaces a whole range of rows at once, starting at the given index, which a
     *  row major table hands to a {@link javax.swing.JTable} as a single update of a
     *  range of rows. This is the operation you want when a batch of your data changed
     *  and you would rather not rebuild the whole table for it.
     * @param rowIndex The index of the first row to replace.
     * @param rows     The new rows, each holding one cell value per column.
     * @return A new {@link TableData} with the replaced rows, or this table unchanged
     *         if the range is out of bounds or there is nothing to replace.
     */
    public TableData setRowsAt( int rowIndex, Tuple<Tuple<@Nullable Object>> rows ) {
        Objects.requireNonNull(rows);
        if ( rowIndex < 0 || rows.isEmpty() || rowIndex + rows.size() > _rowCount )
            return this;
        if ( _layout.isRowMajor() )
            return _withCells(_cells.setAllAt(rowIndex, rows));
        return _withCells(_replaceMinors(rowIndex, rows));
    }

    /**
     *  Removes the row at the given index.
     * @param rowIndex The index of the row to remove.
     * @return A new {@link TableData} without that row, or this table unchanged
     *         if the index is out of bounds.
     */
    public TableData removeRowAt( int rowIndex ) {
        return removeRowsAt(rowIndex, 1);
    }

    /**
     *  Removes a whole range of rows at once, which a row major table hands to a
     *  {@link javax.swing.JTable} as a single deletion of a range of rows.
     * @param rowIndex The index of the first row to remove.
     * @param count    The number of rows to remove.
     * @return A new {@link TableData} without those rows, or this table unchanged
     *         if the range is out of bounds or there is nothing to remove.
     */
    public TableData removeRowsAt( int rowIndex, int count ) {
        if ( rowIndex < 0 || count <= 0 || rowIndex + count > _rowCount )
            return this;
        if ( _layout.isRowMajor() )
            return _withCells(_cells.removeAt(rowIndex, count));
        return _withCells(_removeMinors(rowIndex, count));
    }

    /**
     *  Removes every row, while keeping the columns of this table exactly as they are.
     *  Reach for {@link #empty()} instead if you want to be rid of the columns too.
     * @return A new {@link TableData} with the same columns but no rows.
     */
    public TableData removeAllRows() {
        return removeRowsAt(0, _rowCount);
    }

    // ---- Changing columns ----

    /**
     *  Appends a column to the right of this table, with a name, a class and its cells:
     *  <pre>{@code
     *  data.addColumn("City", String.class, TableData.row("Rome", "Oslo"))
     *  }</pre>
     * @param columnName  The name of the new column.
     * @param columnClass The class of the new column.
     * @param values      The cells of the new column, one per row.
     * @return A new {@link TableData} with the additional column.
     */
    public TableData addColumn( @Nullable String columnName, Class<?> columnClass, Tuple<@Nullable Object> values ) {
        return addColumnAt(_columnCount, columnName, columnClass, values);
    }

    /**
     *  Inserts a column at the given index, with a name, a class and its cells.
     * @param columnIndex The index the new column will have.
     * @param columnName  The name of the new column.
     * @param columnClass The class of the new column.
     * @param values      The cells of the new column, one per row.
     * @return A new {@link TableData} with the additional column, or this table
     *         unchanged if the index is out of bounds.
     */
    public TableData addColumnAt(
        int                     columnIndex,
        @Nullable String        columnName,
        Class<?>                columnClass,
        Tuple<@Nullable Object> values
    ) {
        Objects.requireNonNull(columnClass);
        Objects.requireNonNull(values);
        if ( columnIndex < 0 || columnIndex > _columnCount )
            return this;
        Tuple<@Nullable String> names   = _padNamesTo(_columnCount).addAt(columnIndex, columnName);
        Tuple<Class<?>>         classes = _padClassesTo(_columnCount).addAt(columnIndex, columnClass);
        Tuple<Tuple<@Nullable Object>> cells =
                _layout.isRowMajor()
                    ? _insertMinors(columnIndex, Tuple.of(LINE_TYPE, values), _columnCount)
                    // Metadata may describe more columns than the cells fill, so the
                    // cells are padded up to the insertion point first - clamping the
                    // index instead would land the new cells under the wrong column.
                    : _padLines(columnIndex).addAllAt(columnIndex, Tuple.of(LINE_TYPE, values));
        return new TableData(_layout, names, classes, cells);
    }

    /**
     *  Replaces the cells of the column at the given index, leaving its name and
     *  class alone.
     * @param columnIndex The index of the column to replace.
     * @param values      The new cells of the column, one per row.
     * @return A new {@link TableData} with the replaced column, or this table
     *         unchanged if the index is out of bounds.
     */
    public TableData setColumnAt( int columnIndex, Tuple<@Nullable Object> values ) {
        Objects.requireNonNull(values);
        if ( columnIndex < 0 || columnIndex >= _columnCount )
            return this;
        if ( !_layout.isRowMajor() )
            return _withCells(_padLines(columnIndex + 1).setAt(columnIndex, values));
        return _withCells(_replaceMinors(columnIndex, Tuple.of(LINE_TYPE, values)));
    }

    /**
     *  Removes the column at the given index, along with its name and class.
     * @param columnIndex The index of the column to remove.
     * @return A new {@link TableData} without that column, or this table unchanged
     *         if the index is out of bounds.
     */
    public TableData removeColumnAt( int columnIndex ) {
        return removeColumnsAt(columnIndex, 1);
    }

    /**
     *  Removes a whole range of columns at once, along with their names and classes.
     * @param columnIndex The index of the first column to remove.
     * @param count       The number of columns to remove.
     * @return A new {@link TableData} without those columns, or this table unchanged
     *         if the range is out of bounds or there is nothing to remove.
     */
    public TableData removeColumnsAt( int columnIndex, int count ) {
        if ( columnIndex < 0 || count <= 0 || columnIndex + count > _columnCount )
            return this;
        Tuple<@Nullable String> names   = _removeFrom(_padNamesTo(_columnCount), columnIndex, count);
        Tuple<Class<?>>         classes = _removeFrom(_padClassesTo(_columnCount), columnIndex, count);
        Tuple<Tuple<@Nullable Object>> cells =
                _layout.isRowMajor()
                    ? _removeMinors(columnIndex, count)
                    : _removeFrom(_cells, columnIndex, count);
        return new TableData(_layout, names, classes, cells);
    }

    /**
     *  Renames a single column, which a table picks up as a change of its header.
     * @param columnIndex The index of the column to rename.
     * @param columnName  The new name of the column, where {@code null} means
     *                    "fall back to the default (spreadsheet style) name".
     * @return A new {@link TableData} with the renamed column, or this table unchanged
     *         if the index is out of bounds or the name did not change.
     */
    public TableData setColumnNameAt( int columnIndex, @Nullable String columnName ) {
        if ( columnIndex < 0 || columnIndex >= _columnCount )
            return this;
        if ( Objects.equals(getColumnName(columnIndex), columnName) )
            return this;
        return new TableData(
                    _layout,
                    _padNamesTo(_columnCount).setAt(columnIndex, columnName),
                    _columnClasses,
                    _cells
                );
    }

    /**
     *  Renames every column at once.
     * @param columnNames The new column names, one per column.
     * @return A new {@link TableData} with the new column names.
     */
    public TableData setColumnNames( String... columnNames ) {
        Objects.requireNonNull(columnNames);
        return setColumnNames(Tuple.ofNullable(String.class, columnNames));
    }

    /**
     *  Renames every column at once. Note that the number of names takes part in
     *  deciding how many columns this table has, so handing over more names than
     *  the cells fill widens the table.
     * @param columnNames The new column names, one per column.
     * @return A new {@link TableData} with the new column names.
     */
    public TableData setColumnNames( Tuple<@Nullable String> columnNames ) {
        Objects.requireNonNull(columnNames);
        return new TableData(_layout, columnNames, _columnClasses, _cells);
    }

    /**
     *  Changes the class of a single column, which is how you tell a
     *  {@link javax.swing.JTable} to render and edit it differently (a
     *  {@link Boolean} column becomes a column of check boxes, say).
     * @param columnIndex The index of the column.
     * @param columnClass The new class of the column.
     * @return A new {@link TableData} with the new column class, or this table
     *         unchanged if the index is out of bounds or the class did not change.
     */
    public TableData setColumnClassAt( int columnIndex, Class<?> columnClass ) {
        Objects.requireNonNull(columnClass);
        if ( columnIndex < 0 || columnIndex >= _columnCount )
            return this;
        if ( getColumnClass(columnIndex).equals(columnClass) )
            return this;
        return new TableData(
                    _layout,
                    _columnNames,
                    _padClassesTo(_columnCount).setAt(columnIndex, columnClass),
                    _cells
                );
    }

    /**
     *  Changes the class of every column at once.
     * @param columnClasses The new column classes, one per column.
     * @return A new {@link TableData} with the new column classes.
     */
    public TableData setColumnClasses( Tuple<Class<?>> columnClasses ) {
        Objects.requireNonNull(columnClasses);
        return new TableData(_layout, _columnNames, columnClasses, _cells);
    }

    // ---- Changing everything ----

    /**
     *  Replaces all of the cells of this table at once, keeping its layout and columns.
     * @param cells The new cell values, an immutable tuple of rows (or columns, see {@link #layout()}).
     * @return A new {@link TableData} with the new cells.
     */
    public TableData setCells( Tuple<Tuple<@Nullable Object>> cells ) {
        Objects.requireNonNull(cells);
        return _withCells(cells);
    }

    /**
     *  Changes the layout of this table, which is how you make a table editable
     *  (or read only again) without touching a single cell:
     *  <pre>{@code
     *  data.withLayout(UI.ListData.ROW_MAJOR_EDITABLE)
     *  }</pre>
     *  <b>Careful:</b> the layout also decides how the {@link #cells()} are read, so
     *  switching between a {@code ROW_MAJOR*} and a {@code COLUMN_MAJOR*} layout
     *  reinterprets the very same cells along the other axis, which transposes what
     *  the table shows.
     *
     * @param layout The new {@link UI.ListData} layout of this table.
     * @return A new {@link TableData} with the given layout, or this table unchanged
     *         if it already has it.
     */
    public TableData withLayout( UI.ListData layout ) {
        Objects.requireNonNull(layout);
        if ( _layout == layout )
            return this;
        return new TableData(layout, _columnNames, _columnClasses, _cells);
    }

    // ---- Internals ----

    private int _majorOf( int rowIndex, int columnIndex ) {
        return _layout.isRowMajor() ? rowIndex : columnIndex;
    }

    private int _minorOf( int rowIndex, int columnIndex ) {
        return _layout.isRowMajor() ? columnIndex : rowIndex;
    }

    private Tuple<@Nullable Object> _lineAt( int majorIndex ) {
        if ( majorIndex < 0 || majorIndex >= _cells.size() )
            return EMPTY_LINE;
        return _cells.get(majorIndex);
    }

    private @Nullable Object _cellAt( int majorIndex, int minorIndex ) {
        Tuple<@Nullable Object> line = _lineAt(majorIndex);
        if ( minorIndex < 0 || minorIndex >= line.size() )
            return null;
        return line.get(minorIndex);
    }

    /** Reads one value out of every major line, which is how the minor axis is read. */
    private Tuple<@Nullable Object> _gatherAcrossLines( int minorIndex, int length ) {
        List<@Nullable Object> values = new ArrayList<>(length);
        for ( int i = 0; i < length; i++ )
            values.add(_cellAt(i, minorIndex));
        return Tuple.ofNullable(Object.class, values);
    }

    /**
     *  Inserts one value into every major line, which is how a row is added to a
     *  column major table (and a column to a row major one). The supplied lines are
     *  the new minor lines, each holding one value per major line.
     */
    private Tuple<Tuple<@Nullable Object>> _insertMinors(
        int minorIndex, Tuple<Tuple<@Nullable Object>> minorLines, int minorCount
    ) {
        int majorCount = _cells.size();
        for ( Tuple<@Nullable Object> minorLine : minorLines )
            majorCount = Math.max(majorCount, minorLine.size());
        List<Tuple<@Nullable Object>> lines = new ArrayList<>(majorCount);
        for ( int major = 0; major < majorCount; major++ ) {
            List<@Nullable Object> inserted = new ArrayList<>(minorLines.size());
            for ( Tuple<@Nullable Object> minorLine : minorLines )
                inserted.add(major < minorLine.size() ? minorLine.get(major) : null);
            lines.add(
                _padTo(_lineAt(major), minorCount)
                    .addAllAt(minorIndex, Tuple.ofNullable(Object.class, inserted))
            );
        }
        return Tuple.of(LINE_TYPE, lines);
    }

    /** Replaces one value in every major line, the mirror image of {@link #_insertMinors}. */
    private Tuple<Tuple<@Nullable Object>> _replaceMinors( int minorIndex, Tuple<Tuple<@Nullable Object>> minorLines ) {
        int majorCount = _cells.size();
        for ( Tuple<@Nullable Object> minorLine : minorLines )
            majorCount = Math.max(majorCount, minorLine.size());
        List<Tuple<@Nullable Object>> lines = new ArrayList<>(majorCount);
        for ( int major = 0; major < majorCount; major++ ) {
            List<@Nullable Object> replacements = new ArrayList<>(minorLines.size());
            for ( Tuple<@Nullable Object> minorLine : minorLines )
                replacements.add(major < minorLine.size() ? minorLine.get(major) : null);
            lines.add(
                _padTo(_lineAt(major), minorIndex + replacements.size())
                    .setAllAt(minorIndex, Tuple.ofNullable(Object.class, replacements))
            );
        }
        return Tuple.of(LINE_TYPE, lines);
    }

    /** Removes a range of values from every major line, the mirror image of {@link #_insertMinors}. */
    private Tuple<Tuple<@Nullable Object>> _removeMinors( int minorIndex, int count ) {
        List<Tuple<@Nullable Object>> lines = new ArrayList<>(_cells.size());
        for ( Tuple<@Nullable Object> line : _cells )
            lines.add(_removeFrom(line, minorIndex, count));
        return Tuple.of(LINE_TYPE, lines);
    }

    private static Tuple<@Nullable Object> _padTo( Tuple<@Nullable Object> line, int length ) {
        if ( line.size() >= length )
            return line;
        List<@Nullable Object> padding = new ArrayList<>(length - line.size());
        for ( int i = line.size(); i < length; i++ )
            padding.add(null);
        return line.addAll(Tuple.ofNullable(Object.class, padding));
    }

    private Tuple<Tuple<@Nullable Object>> _padLines( int length ) {
        if ( _cells.size() >= length )
            return _cells;
        List<Tuple<@Nullable Object>> padding = new ArrayList<>(length - _cells.size());
        for ( int i = _cells.size(); i < length; i++ )
            padding.add(EMPTY_LINE);
        return _cells.addAll(Tuple.of(LINE_TYPE, padding));
    }

    private Tuple<@Nullable String> _padNamesTo( int length ) {
        if ( _columnNames.size() >= length )
            return _columnNames;
        List<@Nullable String> padding = new ArrayList<>(length - _columnNames.size());
        for ( int i = _columnNames.size(); i < length; i++ )
            padding.add(null);
        return _columnNames.addAll(Tuple.ofNullable(String.class, padding));
    }

    private Tuple<Class<?>> _padClassesTo( int length ) {
        if ( _columnClasses.size() >= length )
            return _columnClasses;
        List<Class<?>> padding = new ArrayList<>(length - _columnClasses.size());
        for ( int i = _columnClasses.size(); i < length; i++ )
            padding.add(Object.class);
        return _columnClasses.addAll(Tuple.of(CLASS_TYPE, padding));
    }

    private static <T extends @Nullable Object> Tuple<T> _removeFrom( Tuple<T> tuple, int index, int count ) {
        int from = Math.min(Math.max(0, index), tuple.size());
        int to   = Math.min(from + count, tuple.size());
        return from < to ? tuple.removeRange(from, to) : tuple;
    }

    private TableData _withCells( Tuple<Tuple<@Nullable Object>> cells ) {
        if ( cells.equals(_cells) )
            return this;
        return new TableData(_layout, _columnNames, _columnClasses, cells);
    }

    @Override
    public boolean equals( Object obj ) {
        if ( obj == this ) return true;
        if ( !(obj instanceof TableData) ) return false;
        TableData other = (TableData) obj;
        // Note that the two dimensions are derived from the state below, so they take care of themselves.
        return _layout == other._layout
            && _columnNames.equals(other._columnNames)
            && _columnClasses.equals(other._columnClasses)
            && _cells.equals(other._cells);
    }

    @Override
    public int hashCode() {
        return Objects.hash(_layout, _columnNames, _columnClasses, _cells);
    }

    @Override
    public String toString() {
        return this.getClass().getSimpleName() + "[" +
                    "layout="      + _layout        + ", " +
                    "rowCount="    + _rowCount      + ", " +
                    "columnCount=" + _columnCount   + ", " +
                    "columnNames=" + _columnNames   + ", " +
                    "cells="       + _cells         +
                "]";
    }
}