AdjusterTableColumn.java

package com.jsql.view.swing.table;

import javax.swing.*;
import javax.swing.event.TableModelEvent;
import javax.swing.event.TableModelListener;
import javax.swing.table.TableCellRenderer;
import javax.swing.table.TableColumn;
import javax.swing.table.TableColumnModel;
import javax.swing.table.TableModel;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.util.HashMap;
import java.util.Map;

/**
 * Class to manage the widths of columns in a table.
 *
 * Various properties control how the width of the column is calculated.
 * Another property controls whether column width calculation should be dynamic.
 * Finally, various Actions will be added to the table to allow the user
 * to customize the functionality.
 *
 * This class was designed to be used with tables that use an auto resize mode
 * of AUTO_RESIZE_OFF. With all other modes you are constrained as the width
 * of the columns must fit inside the table. So if you increase one column, one
 * or more of the other columns must decrease. Because of this the resize mode
 * of RESIZE_ALL_COLUMNS will work the best.
 */
public class AdjusterTableColumn implements PropertyChangeListener, TableModelListener {
    
    private final JTable tableAdjust;
    private final int spacing;
    private boolean isColumnHeaderIncluded;
    private boolean isColumnDataIncluded;
    private boolean isOnlyAdjustLarger;
    private boolean isDynamicAdjustment;
    private final Map<TableColumn, Integer> columnSizes = new HashMap<>();

    /**
     * Specify the table and use default spacing
     */
    public AdjusterTableColumn(JTable table) {
        this(table, 6);
    }

    /**
     * Specify the table and spacing
     */
    public AdjusterTableColumn(JTable tableAdjust, int spacing) {
        
        this.tableAdjust = tableAdjust;
        
        final TableCellRenderer tcrOs = tableAdjust.getTableHeader().getDefaultRenderer();
        tableAdjust.getTableHeader().setDefaultRenderer(
            (JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) -> {
                    
                JLabel label = (JLabel) tcrOs.getTableCellRendererComponent(
                    table, value, isSelected, hasFocus, row, column
                );
                
                label.setBackground(new Color(230, 230, 230));
                
                return label;
            }
        );
        
        this.spacing = spacing;
        this.setColumnHeaderIncluded(true);
        this.setColumnDataIncluded(true);
        this.setOnlyAdjustLarger(true);
        this.setDynamicAdjustment(false);
        this.installActions();
    }

    /**
     * Adjust the widths of all the columns in the table
     */
    public void adjustColumns() {
        
        TableColumnModel tcm = this.tableAdjust.getColumnModel();

        for (var i = 0 ; i < tcm.getColumnCount() ; i++) {
            this.adjustColumn(i);
        }
    }

    /**
     * Adjust the width of the specified column in the table
     */
    public void adjustColumn(final int column) {
        
        var tableColumn = this.tableAdjust.getColumnModel().getColumn(column);

        if (!tableColumn.getResizable()) {
            return;
        }

        int columnHeaderWidth = this.getColumnHeaderWidth(column);
        int columnDataWidth   = this.getColumnDataWidth(column);
        int preferredWidth    = Math.max(columnHeaderWidth, columnDataWidth);

        this.updateTableColumn(column, preferredWidth);
    }

    /**
     * Calculated the width based on the column name
     */
    private int getColumnHeaderWidth(int column) {
        
        if (!this.isColumnHeaderIncluded) {
            return 0;
        }

        var tableColumn = this.tableAdjust.getColumnModel().getColumn(column);
        Object value = tableColumn.getHeaderValue();
        TableCellRenderer renderer = tableColumn.getHeaderRenderer();

        if (renderer == null) {
            renderer = this.tableAdjust.getTableHeader().getDefaultRenderer();
        }

        var c = renderer.getTableCellRendererComponent(this.tableAdjust, value, false, false, -1, column);
        
        return c.getPreferredSize().width;
    }

    /**
     * Calculate the width based on the widest cell renderer for the
     * given column.
     */
    private int getColumnDataWidth(int column) {
        
        if (!this.isColumnDataIncluded) {
            return 0;
        }

        var preferredWidth = 0;
        int maxWidth = this.tableAdjust.getColumnModel().getColumn(column).getMaxWidth();

        for (var row = 0 ; row < this.tableAdjust.getRowCount() ; row++) {
            
            preferredWidth = Math.max(preferredWidth, this.getCellDataWidth(row, column));

            // We've exceeded the maximum width, no need to check other rows
            if (preferredWidth >= maxWidth) {
                break;
            }
        }

        return preferredWidth;
    }

    /**
     * Get the preferred width for the specified cell
     */
    private int getCellDataWidth(int row, int column) {
        
        // Invoke the renderer for the cell to calculate the preferred width
        TableCellRenderer cellRenderer = this.tableAdjust.getCellRenderer(row, column);
        Component c = this.tableAdjust.prepareRenderer(cellRenderer, row, column);
        
        return c.getPreferredSize().width + this.tableAdjust.getIntercellSpacing().width;
    }

    /**
     * Update the TableColumn with the newly calculated width
     */
    private void updateTableColumn(int column, int width) {
        
        final var tableColumn = this.tableAdjust.getColumnModel().getColumn(column);

        if (!tableColumn.getResizable()) {
            return;
        }

        int calculatedWidth = width;
        calculatedWidth += this.spacing;

        // Don't shrink the column width
        if (this.isOnlyAdjustLarger) {
            calculatedWidth = Math.max(calculatedWidth, tableColumn.getPreferredWidth());
        }

        this.columnSizes.put(tableColumn, tableColumn.getWidth());
        this.tableAdjust.getTableHeader().setResizingColumn(tableColumn);
        tableColumn.setWidth(calculatedWidth);
    }

    /**
     * Restore the widths of the columns in the table to its previous width
     */
    public void restoreColumns() {
        
        TableColumnModel tableColumnModel = this.tableAdjust.getColumnModel();

        for (var i = 0 ; i < tableColumnModel.getColumnCount() ; i++) {
            this.restoreColumn(i);
        }
    }

    /**
     * Restore the width of the specified column to its previous width
     */
    private void restoreColumn(int column) {
        
        var tableColumn = this.tableAdjust.getColumnModel().getColumn(column);
        Integer width = this.columnSizes.get(tableColumn);

        if (width != null) {
            
            this.tableAdjust.getTableHeader().setResizingColumn(tableColumn);
            tableColumn.setWidth(width);
        }
    }

    /**
     * Indicates whether to include the header in the width calculation
     */
    public void setColumnHeaderIncluded(boolean isColumnHeaderIncluded) {
        this.isColumnHeaderIncluded = isColumnHeaderIncluded;
    }

    /**
     * Indicates whether to include the model data in the width calculation
     */
    public void setColumnDataIncluded(boolean isColumnDataIncluded) {
        this.isColumnDataIncluded = isColumnDataIncluded;
    }

    /**
     * Indicates whether columns can only be increased in size
     */
    public void setOnlyAdjustLarger(boolean isOnlyAdjustLarger) {
        this.isOnlyAdjustLarger = isOnlyAdjustLarger;
    }

    /**
     * Indicate whether changes to the model should cause the width to be
     * dynamically recalculated.
     */
    public void setDynamicAdjustment(boolean isDynamicAdjustment) {
        
        // May need to add or remove the TableModelListener when changed
        if (this.isDynamicAdjustment != isDynamicAdjustment) {
            if (isDynamicAdjustment) {
                
                this.tableAdjust.addPropertyChangeListener(this);
                this.tableAdjust.getModel().addTableModelListener(this);
                
            } else {
                
                this.tableAdjust.removePropertyChangeListener(this);
                this.tableAdjust.getModel().removeTableModelListener(this);
            }
        }

        this.isDynamicAdjustment = isDynamicAdjustment;
    }
    
    /**
     * Implement the PropertyChangeListener
     */
    @Override
    public void propertyChange(PropertyChangeEvent e) {
        // When the TableModel changes we need to update the listeners
        // and column widths
        if ("model".equals(e.getPropertyName())) {
            
            TableModel model = (TableModel)e.getOldValue();
            model.removeTableModelListener(this);

            model = (TableModel)e.getNewValue();
            model.addTableModelListener(this);
            this.adjustColumns();
        }
    }
    
    /**
     * Implement the TableModelListener
     */
    @Override
    public void tableChanged(TableModelEvent e) {
        
        if (!this.isColumnDataIncluded) {
            return;
        }

        // A cell has been updated
        if (e.getType() == TableModelEvent.UPDATE) {
            
            int column = this.tableAdjust.convertColumnIndexToView(e.getColumn());

            // Only need to worry about an increase in width for this cell
            if (this.isOnlyAdjustLarger) {
                
                int row = e.getFirstRow();
                var tableColumn = this.tableAdjust.getColumnModel().getColumn(column);

                if (tableColumn.getResizable()) {
                    
                    int width = this.getCellDataWidth(row, column);
                    this.updateTableColumn(column, width);
                }
            } else {
                this.adjustColumn(column);  // Could be an increase of decrease so check all rows
            }
        } else {
            this.adjustColumns();  // The update affected more than one column so adjust all columns
        }
    }

    /**
     * Install Actions to give user control of certain functionality.
     */
    private void installActions() {
        
        this.installColumnAction(true,  true,  "adjustColumn",   "control ADD");
        this.installColumnAction(false, true,  "adjustColumns",  "control shift ADD");
        this.installColumnAction(true,  false, "restoreColumn",  "control SUBTRACT");
        this.installColumnAction(false, false, "restoreColumns", "control shift SUBTRACT");

        this.installToggleAction(true,  false, "toggleDynamic",  "control MULTIPLY");
        this.installToggleAction(false, true,  "toggleLarger",   "control DIVIDE");
    }

    /**
     * Update the input and action maps with a new ColumnAction
     */
    private void installColumnAction(boolean isSelectedColumn, boolean isAdjust, String key, String keyStroke) {
        
        Action action = new ColumnAction(isSelectedColumn, isAdjust);
        var ks = KeyStroke.getKeyStroke(keyStroke);
        
        this.tableAdjust.getInputMap().put(ks, key);
        this.tableAdjust.getActionMap().put(key, action);
    }

    /**
     * Update the input and action maps with new ToggleAction
     */
    private void installToggleAction(boolean isToggleDynamic, boolean isToggleLarger, String key, String keyStroke) {
        
        Action action = new ToggleAction(isToggleDynamic, isToggleLarger);
        var ks = KeyStroke.getKeyStroke(keyStroke);
        
        this.tableAdjust.getInputMap().put(ks, key);
        this.tableAdjust.getActionMap().put(key, action);
    }

    /**
     * Action to adjust or restore the width of a single column or all columns
     */
    class ColumnAction extends AbstractAction {
        
        private final boolean isSelectedColumn;
        private final boolean isAdjust;

        public ColumnAction(boolean isSelectedColumn, boolean isAdjust) {
            
            this.isSelectedColumn = isSelectedColumn;
            this.isAdjust = isAdjust;
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            // Handle selected column(s) width change actions
            if (this.isSelectedColumn) {
                
                int[] columns = AdjusterTableColumn.this.tableAdjust.getSelectedColumns();

                for (int column: columns) {
                    if (this.isAdjust) {
                        AdjusterTableColumn.this.adjustColumn(column);
                    } else {
                        AdjusterTableColumn.this.restoreColumn(column);
                    }
                }
            } else {
                if (this.isAdjust) {
                    AdjusterTableColumn.this.adjustColumns();
                } else {
                    AdjusterTableColumn.this.restoreColumns();
                }
            }
        }
    }

    /**
     * Toggle properties of the TableColumnAdjuster so the user can
     * customize the functionality to their preferences
     */
    class ToggleAction extends AbstractAction {
        
        private final boolean isToggleDynamic;
        private final boolean isToggleLarger;

        public ToggleAction(boolean isToggleDynamic, boolean isToggleLarger) {
            
            this.isToggleDynamic = isToggleDynamic;
            this.isToggleLarger = isToggleLarger;
        }

        @Override
        public void actionPerformed(ActionEvent e) {
            if (this.isToggleDynamic) {
                AdjusterTableColumn.this.setDynamicAdjustment(!AdjusterTableColumn.this.isDynamicAdjustment);
            } else if (this.isToggleLarger) {
                AdjusterTableColumn.this.setOnlyAdjustLarger(!AdjusterTableColumn.this.isOnlyAdjustLarger);
            }
        }
    }
}