/******************************************************************************* * Breakout Cave Survey Visualizer * * Copyright (C) 2014 James Edwards * * jedwards8 at fastmail dot fm * * This program is free software; you can redistribute it and/or modify it under * the terms of the GNU General Public License as published by the Free Software * Foundation; either version 2 of the License, or (at your option) any later * version. * * This program is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more * details. * * You should have received a copy of the GNU General Public License along with * this program; if not, write to the Free Software Foundation, Inc., 51 * Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. *******************************************************************************/ package org.andork.swing.table.old; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Queue; import java.util.regex.Pattern; import javax.swing.SwingUtilities; import javax.swing.event.TableModelEvent; import javax.swing.event.TableModelListener; import javax.swing.table.AbstractTableModel; import javax.swing.table.TableModel; import org.andork.util.Java7; /** * Provides an automatically-updated filtered view of a backing table model. The * filter can be any implementation of the {@link Filter} interface.<br> * <br> * * If more than a set number of rows have to be filtered at once, the filtering * will be performed on a background thread to keep from tying up the Swing * thread. In this case, the filtered data will be temporarily out-of-date, but * {@link #isRebuildingInBackground()} will return {@code true}.<br> * <br> * * You can make the table display a wait cursor when background filtering is * occuring using a {@link TableModelListener}: * * <pre> * filteringTableModel.addTableModelListener(new TableModelListener() { * public void tableChanged(TableModelEvent e) { * Cursor cursor = null; * if (filteringTableModel.isRebuildingInBackground()) { * cursor = Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR); * } * table.setCursor(cursor); * } * }); * </pre> * * @author james.a.edwards */ @SuppressWarnings("serial") public class FilteringTableModel extends AbstractTableModel { /** * Creates a filter that only includes rows that match each filter in an * array. * * @author james.a.edwards */ public static class AndFilter implements Filter { private Filter[] filters; /** * Creates a filter that only includes rows that match each filter in * {@code filters}. * * @param filters * the filters to use. */ public AndFilter(Filter... filters) { this.filters = filters; } public AndFilter(List<Filter> filters) { this.filters = filters .toArray(new Filter[filters.size()]); } @Override public boolean equals(Object o) { if (o == null) { return false; } if (o instanceof AndFilter) { AndFilter f = (AndFilter) o; if (filters.length != f.filters.length) { return false; } for (int i = 0; i < filters.length; i++) { if (!filters[i].equals(f.filters[i])) { return false; } } return true; } return false; } @Override public Object[] include(Object[] row) { Object[] filterResult = new Object[filters.length]; for (int i = 0; i < filters.length; i++) { filterResult[i] = filters[i].include(row); if (filterResult[i] == null) { return null; } } return filterResult; } } private class BaseModelListener implements TableModelListener { protected void handleSmallAppend(TableModelEvent e) { int firstFilteredRow = rows.size(); for (int originalRow = e.getFirstRow(); originalRow <= e .getLastRow(); originalRow++) { Object[] row = new Object[backingModel.getColumnCount()]; for (int column = 0; column < backingModel.getColumnCount(); column++) { row[column] = backingModel.getValueAt(originalRow, column); } Object filterResult = null; if (filter != null) { filterResult = filter.include(row); } if (filter == null || filterResult != null) { rows.add(row); filterResults.add(filterResult); backingRowIndices.add(new Integer(originalRow)); } } int lastFilteredRow = rows.size() - 1; if (lastFilteredRow >= firstFilteredRow) { fireTableRowsInserted(firstFilteredRow, lastFilteredRow); } } protected void handleSmallUpdate(TableModelEvent e) { for (int originalRow = e.getLastRow(); originalRow >= e .getFirstRow(); originalRow--) { int filterIndex = Collections.binarySearch(backingRowIndices, new Integer(originalRow)); if (filterIndex >= 0) { Object[] row = new Object[backingModel.getColumnCount()]; for (int column = 0; column < backingModel.getColumnCount(); column++) { row[column] = backingModel.getValueAt(originalRow, column); } Object filterResult = null; if (filter != null) { filterResult = filter.include(row); } if (filter == null || filterResult != null) { rows.set(filterIndex, row); filterResults.set(filterIndex, filterResult); } else { rows.remove(filterIndex); filterResults.remove(filterIndex); backingRowIndices.remove(filterIndex); } } } fireTableDataChanged(); } @Override public void tableChanged(TableModelEvent e) { if (e.getType() == TableModelEvent.UPDATE && e.getLastRow() - e.getFirstRow() <= smallChangeLimit) { handleSmallUpdate(e); } else if (e.getType() == TableModelEvent.INSERT && e.getLastRow() == backingModel.getRowCount() - 1 && e.getLastRow() - e.getFirstRow() <= smallChangeLimit) { handleSmallAppend(e); } else if (backingModel.getRowCount() <= smallChangeLimit) { rebuildAndWait(); } else { rebuildLater(); } } } /** * Determines which rows a {@link FilteringTableModel} will include from its * backing {@link TableModel}. * * @author andy.edwards */ public static interface Filter { /** * @param row * a row of data copied from the backing {@link TableModel}. * @return if the row should be included, any non-null {@code Object}. * This object may represent info about the filter result such * as where exactly a regular expression matched. If the row * should not be included, the implementation should return * {@code null}. */ public Object include(Object[] row); } /** * A filter that applies a delegate filter to determine which rows to * include, and marks whether included rows passed one of a list of * highlighting filters. This is so that a table can highlight rows that * passed a given filter. It will indicate if a row passed any highlighting * filter via a {@link HighlightingFilterResult} for that row. * * @author james.a.edwards */ public static class HighlightingFilter implements Filter { private Filter mainFilter; private Filter[] highlightingFilters; public HighlightingFilter(Filter mainFilter, Filter... highlightingFilters) { super(); this.mainFilter = mainFilter; this.highlightingFilters = highlightingFilters; } @Override public Object include(Object[] row) { Object mainFilterResult = null; if (mainFilter != null) { mainFilterResult = mainFilter.include(row); } if (mainFilter == null || mainFilterResult != null) { Filter highlightingFilter = null; Object highlightingFilterResult = null; for (int i = 0; i < highlightingFilters.length; i++) { highlightingFilterResult = highlightingFilters[i] .include(row); if (highlightingFilterResult != null) { highlightingFilter = highlightingFilters[i]; break; } } return new HighlightingFilterResult(mainFilterResult, highlightingFilter, highlightingFilterResult); } return null; } } public static class HighlightingFilterResult { public final Object mainFilterResult; public final Filter highlightingFilter; public final Object highlightingFilterResult; HighlightingFilterResult(Object mainFilterResult, Filter highlightingFilter, Object highlightingFilterResult) { super(); this.mainFilterResult = mainFilterResult; this.highlightingFilter = highlightingFilter; this.highlightingFilterResult = highlightingFilterResult; } } /** * A filter that includes all rows whose {@code value.toString()} in a * specified {@code column} (or any column) contains a match for a specified * {@code pattern} (the pattern does not have to match the entire value). * * @author james.a.edwards */ public static class PatternFilter implements Filter { private Pattern pattern; private Integer column; /** * Creates a filter that includes all rows whose * {@code value.toString()} in any column contains a match for the * specified {@code pattern} (the pattern does not have to match the * entire value). * * @param pattern * the filter pattern. * @param column * the column to look for matches in. */ public PatternFilter(Pattern pattern) { this.pattern = pattern; } /** * Creates a filter that includes all rows whose * {@code value.toString()} in the specified {@code column} contains a * match for the specified {@code pattern} (the pattern does not have to * match the entire value). * * @param pattern * the filter pattern. * @param column * the column to look for matches in. */ public PatternFilter(Pattern pattern, int column) { this.pattern = pattern; this.column = column; } @Override public boolean equals(Object o) { if (o == null) { return false; } if (o instanceof PatternFilter) { PatternFilter f = (PatternFilter) o; return Java7.Objects.equals(column, f.column) && pattern.equals(f.pattern); } return false; } public Integer getColumn() { return column; } public Pattern getPattern() { return pattern; } @Override public Object include(Object[] row) { if (column != null) { Object value = row[column]; return value != null && pattern.matcher(value.toString()).find() ? Boolean.TRUE : null; } else { StringBuffer combined = new StringBuffer(); for (int column = 0; column < row.length; column++) { Object value = row[column]; if (value != null) { combined.append(value.toString()); } if (column < row.length - 1) { combined.append('\t'); } } return pattern.matcher(combined.toString()).find() ? Boolean.TRUE : null; } } } private class PostRebuild implements Runnable { boolean backingModelChanged; ArrayList<Integer> newBackingRowIndices; ArrayList<Object> newFilterResults; ArrayList<Object[]> newRows; String[] newColumnNames; Class<?>[] newColumnClasses; PostRebuild(String[] newColumnNames, Class<?>[] newColumnClasses, ArrayList<Object[]> newRows, ArrayList<Object> newFilterResults, ArrayList<Integer> newBackingRowIndices) { super(); this.newColumnNames = newColumnNames; this.newColumnClasses = newColumnClasses; this.newRows = newRows; this.newFilterResults = newFilterResults; this.newBackingRowIndices = newBackingRowIndices; } @Override public void run() { backingModelChanged = FilteringTableModel.this.backingModelChanged; if (!backingModelChanged) { rebuildThread = null; } boolean structureChanged = columnNames == null; columnNames = newColumnNames; columnClasses = newColumnClasses; rows = newRows; filterResults = newFilterResults; backingRowIndices = newBackingRowIndices; if (structureChanged) { fireTableStructureChanged(); } else { fireTableDataChanged(); } if (!backingModelChanged) { while (!invokeWhenDoneRebuildingQueue.isEmpty()) { try { invokeWhenDoneRebuildingQueue.poll().run(); } catch (Exception ex) { ex.printStackTrace(); } } } } } private class PreRebuild implements Runnable { String[] newColumnNames; Class<?>[] newColumnClasses; Filter filter; @Override public void run() { filter = FilteringTableModel.this.filter; if (backingModel != null) { newColumnNames = new String[backingModel.getColumnCount()]; newColumnClasses = new Class[backingModel.getColumnCount()]; for (int column = 0; column < backingModel.getColumnCount(); column++) { newColumnNames[column] = backingModel.getColumnName(column); newColumnClasses[column] = backingModel .getColumnClass(column); } } backingModelChanged = false; } } // NOTE: To guarantee thread safety, all FilteringTableModel // instance variables should only be accessed on the Swing // thread! private class Rebuilder implements Runnable { @Override public void run() { // NOTE: To guarantee thread safety, all FilteringTableModel // instance variables should only be accessed on the Swing // thread! boolean backingModelChanged; do { backingModelChanged = false; // copy the filter, column names and classes and clear the // backingModelChanged flag (the instance variable, not the // local variable) on the EDT. PreRebuild preRebuild = new PreRebuild(); doSwing(preRebuild); // Copy the rows from backingModel in chunks on the EDT, so we // don't tie it up. If the backing model is changed during this // process, start over again. ArrayList<Object[]> newRows = new ArrayList<Object[]>(); int k = 0; RowCopier rowCopier = new RowCopier(newRows, 100); while (!rowCopier.complete) { doSwing(rowCopier); if (rowCopier.backingModelChanged) { break; } if (k++ == 0) { doSwing(new Runnable() { @Override public void run() { fireTableDataChanged(); } }); } } if (rowCopier.backingModelChanged) { backingModelChanged = true; continue; } // Now we have a coherent copy of the backing model, and we can // filter it. ArrayList<Integer> newBackingRowIndices = new ArrayList<Integer>(); ArrayList<Object> newFilterResults = new ArrayList<Object>(); for (int row = newRows.size() - 1; row >= 0; row--) { Object filterResult = null; if (preRebuild.filter != null) { filterResult = preRebuild.filter .include(newRows.get(row)); if (filterResult != null) { newFilterResults.add(0, filterResult); } } else { newFilterResults.add(0, null); } if (preRebuild.filter == null || filterResult != null) { newBackingRowIndices.add(0, new Integer(row)); } else { newRows.remove(row); } } // Install the filtered data on the EDT, and check if the // backing // model has been changed again. PostRebuild postRebuild = new PostRebuild( preRebuild.newColumnNames, preRebuild.newColumnClasses, newRows, newFilterResults, newBackingRowIndices); doSwing(postRebuild); backingModelChanged = postRebuild.backingModelChanged; // if the backing model changed after all the data was copied, // start over again. } while (backingModelChanged); } } private class RowCopier implements Runnable { ArrayList<Object[]> newRows; int numRows; boolean backingModelChanged; boolean complete; RowCopier(ArrayList<Object[]> newRows, int numRows) { super(); this.newRows = newRows; this.numRows = numRows; } @Override public void run() { if (backingModel != null) { int endRow = Math.min(newRows.size() + numRows, backingModel.getRowCount()); for (int row = newRows.size(); row < endRow; row++) { Object[] newRow = new Object[backingModel.getColumnCount()]; for (int column = 0; column < backingModel.getColumnCount(); column++) { newRow[column] = backingModel.getValueAt(row, column); } newRows.add(newRow); } complete = newRows.size() == backingModel.getRowCount(); } else { complete = true; } backingModelChanged = FilteringTableModel.this.backingModelChanged; } } /** * */ private static final long serialVersionUID = 6037166296531047090L; private TableModel backingModel; private Filter filter; private BaseModelListener backingModelListener; private boolean backingModelChanged = false; private Thread rebuildThread = null; private String[] columnNames; private Class<?>[] columnClasses; /** * an ArrayList of Object[]s representing the rows */ private ArrayList<Object[]> rows = new ArrayList<Object[]>(); /** * an ArrayList of filter results for each row */ private ArrayList<Object> filterResults = new ArrayList<Object>(); /** * an ArrayList of Integers representing the indices the included rows in * the backing model. */ private ArrayList<Integer> backingRowIndices = new ArrayList<Integer>(); private int smallChangeLimit = 1000; private final Queue<Runnable> invokeWhenDoneRebuildingQueue = new LinkedList<Runnable>(); public FilteringTableModel(TableModel backingModel) { this(backingModel, null); } public FilteringTableModel(TableModel backingModel, Filter filter) { super(); setBackingModel(backingModel); setFilter(filter); } public int convertRowIndexToBackingModel(int rowIndex) { return backingRowIndices.get(rowIndex).intValue(); } private void doSwing(Runnable r) { if (SwingUtilities.isEventDispatchThread()) { r.run(); } else { try { SwingUtilities.invokeAndWait(r); } catch (Exception e) { throw new RuntimeException(e); } } } public TableModel getBackingModel() { return backingModel; } @Override public Class<?> getColumnClass(int columnIndex) { return columnClasses[columnIndex]; } @Override public int getColumnCount() { return columnNames == null ? 0 : columnNames.length; } @Override public String getColumnName(int column) { return columnNames[column]; } public Filter getFilter() { return filter; } public Object getFilterResultForRow(int rowIndex) { return filterResults.get(rowIndex); } @Override public int getRowCount() { return rows.size(); } @Override public Object getValueAt(int rowIndex, int columnIndex) { return rows.get(rowIndex)[columnIndex]; } /** * Queues the given {@link Runnable} to be invoked later when the background * thread has finished rebuilding the filtered results. If the filtered * results are up-to-date and no background thread is running, the * {@code Runnable} will be invoked immediately on the EDT, and this method * will not return until it returns. * * @param r * the {@link Runnable} to invoke * * @throws IllegalStateException * if not called on the EDT */ public void invokeWhenDoneRebuilding(Runnable r) { if (!SwingUtilities.isEventDispatchThread()) { throw new IllegalStateException("Must be called from the EDT"); } if (isRebuildingInBackground()) { invokeWhenDoneRebuildingQueue.add(r); } else { r.run(); } } @Override public boolean isCellEditable(int rowIndex, int columnIndex) { if (isRebuildingInBackground()) { return false; } return backingModel.isCellEditable( backingRowIndices.get(rowIndex).intValue(), columnIndex); } public boolean isRebuildingInBackground() { if (!SwingUtilities.isEventDispatchThread()) { throw new IllegalStateException("Must be called from the EDT"); } return rebuildThread != null; } /** * Makes {@code FilteringTableModel} rebuild the filtered results * immediately on the calling thread (which must be the EDT). This method * does not return until the rebuilding is complete. * * @throws IllegalStateException * if not called on the EDT */ public void rebuildAndWait() { if (!SwingUtilities.isEventDispatchThread()) { throw new IllegalStateException("Must be called from the EDT"); } backingModelChanged = true; new Rebuilder().run(); // Careful! If the backing model changes and this method is run // while a background rebuilder is running, it could cause the // background rebuilder to install obsolete data when it // finishes! So just force it to start over so it will intall // the most up-to-date data. if (isRebuildingInBackground()) { backingModelChanged = true; } } /** * Makes {@code FilteringTableModel} start rebuilding the filtered results * on a background thread. If a rebuild is currently in progress, it will be * aborted and rebuilding will start over. (The rebuilt results will still * be inserted into the table on the EDT.) This method returns immediately; * it does not block until the rebuilding is complete. * * @throws IllegalStateException * if not called on the EDT */ public void rebuildLater() { if (!SwingUtilities.isEventDispatchThread()) { throw new IllegalStateException("Must be called from the EDT"); } backingModelChanged = true; if (rebuildThread == null) { rebuildThread = new Thread(new Rebuilder()); rebuildThread.setName(getClass().getSimpleName() + " Rebuilder"); rebuildThread.start(); } } public void setBackingModel(TableModel backingModel) { if (this.backingModel != backingModel) { if (this.backingModel != null) { this.backingModel .removeTableModelListener(backingModelListener); backingModelListener = null; } this.backingModel = backingModel; if (backingModel != null) { backingModelListener = new BaseModelListener(); this.backingModel.addTableModelListener(backingModelListener); } rebuildLater(); } } public void setFilter(Filter filter) { if (!Java7.Objects.equals(this.filter, filter)) { this.filter = filter; rebuildLater(); } } @Override public void setValueAt(Object aValue, int rowIndex, int columnIndex) { if (isRebuildingInBackground()) { throw new UnsupportedOperationException(); } backingModel.setValueAt(aValue, backingRowIndices.get(rowIndex).intValue(), columnIndex); rebuildLater(); } }