/* Wotonomy: OpenStep design patterns for pure Java applications. Copyright (C) 2000 Intersect Software Corporation This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. This library 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 Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library; if not, see http://www.gnu.org */ package net.wotonomy.ui.swing; import java.awt.EventQueue; import java.awt.Graphics; import java.awt.Rectangle; import java.awt.Toolkit; import java.awt.datatransfer.Clipboard; import java.awt.datatransfer.StringSelection; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.FocusEvent; import java.awt.event.FocusListener; import java.awt.event.KeyEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.util.Enumeration; import javax.swing.CellEditor; import javax.swing.JComponent; import javax.swing.JTable; import javax.swing.KeyStroke; import javax.swing.event.ListSelectionEvent; import javax.swing.event.ListSelectionListener; import javax.swing.table.AbstractTableModel; import javax.swing.table.JTableHeader; import javax.swing.table.TableColumn; import javax.swing.table.TableModel; import net.wotonomy.control.EOSortOrdering; import net.wotonomy.foundation.NSArray; import net.wotonomy.foundation.NSMutableArray; import net.wotonomy.foundation.internal.WotonomyException; import net.wotonomy.ui.EOAssociation; import net.wotonomy.ui.EODisplayGroup; /** * TableAssociation binds one or more TableColumnAssociations to a display * group. You should not instantiate this class directly; use * TableColumnAssociation.setTable() instead. Note that TableAssociation inserts * itself as the controlled JTable's TableModel. * * Bindings are: * * * @author michael@mpowers.net * @author $Author: cgruber $ * @version $Revision: 904 $ */ public class TableAssociation extends EOAssociation implements ActionListener, ListSelectionListener, FocusListener { static final NSArray aspects = new NSArray(new Object[] { SourceAspect, EnabledAspect }); static final NSArray aspectSignatures = new NSArray(new Object[] { AttributeToOneAspectSignature }); static final NSArray objectKeysTaken = new NSArray(new Object[] { "tableModel", "tableHeader" }); // key command to copy contents to clipboard static public final String COPY = "COPY"; EODisplayGroup source; EODisplayGroup sortTarget; // used by TreeColumnAssociation NSMutableArray columns; JTableHeader tableHeader; boolean pleaseIgnore; boolean selectionPaintedImmediately; boolean selectionTracking; /** * Constructor specifying the object to be controlled by this association. * Throws an exception if the object is not a TableColumn. setTable() must be * called before establishing the connection. */ public TableAssociation(Object anObject) { super(anObject); source = null; columns = new NSMutableArray(); JTable aTable = ((JTable) anObject); aTable.addFocusListener(this); aTable.setModel(new TableAssociationModel(this)); // set up keyboard events for cut-copy: Ctrl-C, Ctrl-X // why did sun make this harder to use? // aTable.getInputMap( JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT ).put( // KeyStroke.getKeyStroke( KeyEvent.VK_C, java.awt.Event.CTRL_MASK ), COPY); // aTable.getActionMap().put(COPY, this); aTable.registerKeyboardAction(this, COPY, KeyStroke.getKeyStroke(KeyEvent.VK_C, Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()), JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT); aTable.registerKeyboardAction(this, COPY, KeyStroke.getKeyStroke(KeyEvent.VK_X, Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()), JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT); tableHeader = new SortedTableHeader(); tableHeader.setColumnModel(aTable.getColumnModel()); aTable.setTableHeader(tableHeader); tableHeader.addMouseListener(new TableHeaderListener()); pleaseIgnore = false; selectionPaintedImmediately = false; selectionTracking = false; } /** * Returns a List of aspect signatures whose contents correspond with the * aspects list. Each element is a string whose characters represent a * capability of the corresponding aspect. * * An empty signature "" means that the aspect can bind without needing a key. * This implementation returns "A1M" for each element in the aspects array. */ public static NSArray aspectSignatures() { return aspectSignatures; } /** * Returns a List that describes the aspects supported by this class. Each * element in the list is the string name of the aspect. This implementation * returns an empty list. */ public static NSArray aspects() { return aspects; } /** * Returns a List of EOAssociation subclasses that, for the objects that are * usable for this association, are less suitable than this association. */ public static NSArray associationClassesSuperseded() { return new NSArray(); } /** * Returns whether this class can control the specified object. */ public static boolean isUsableWithObject(Object anObject) { return (anObject instanceof JTable); } /** * Returns a List of properties of the controlled object that are controlled by * this class. For example, "stringValue", or "selected". */ public static NSArray objectKeysTaken() { return objectKeysTaken; } /** * Returns the aspect that is considered primary or default. This is typically * "value" or somesuch. */ public static String primaryAspect() { return ValueAspect; } /** * Returns whether this association can bind to the specified display group on * the specified key for the specified aspect. */ public boolean canBindAspect(String anAspect, EODisplayGroup aDisplayGroup, String aKey) { return (aspects.containsObject(anAspect)); } /** * Binds the specified aspect of this association to the specified key on the * specified display group. */ public void bindAspect(String anAspect, EODisplayGroup aDisplayGroup, String aKey) { if (SourceAspect.equals(anAspect)) { source = aDisplayGroup; } super.bindAspect(anAspect, aDisplayGroup, aKey); } /** * Establishes a connection between this association and the controlled object. * Subclasses should begin listening for events from their controlled object * here. */ public void establishConnection() { if (source == null) { throw new WotonomyException("No display group: " + "please ensure that the TableColumnAssociation " + "has a display group bound to the ValueAspect"); } super.establishConnection(); selectFromDisplayGroup(); addAsListener(); } /** * Breaks the connection between this association and its object. Override to * stop listening for events from the object. */ public void breakConnection() { removeAsListener(); super.breakConnection(); } protected void addAsListener() { component().getSelectionModel().addListSelectionListener(this); } protected void removeAsListener() { component().getSelectionModel().removeListSelectionListener(this); } /** * Forces this association to cause the object to stop editing and validate the * user's input. * * @return false if there were problems validating, or true to continue. */ public boolean endEditing() { // stop any cell editing CellEditor editor = component().getCellEditor(); if (editor != null) { return editor.stopCellEditing(); } return true; } /** * Called when either the selection or the contents of an associated display * group have changed. */ public void subjectChanged() { if (source != null) { if (source.contentsChanged()) { removeAsListener(); ((TableAssociationModel) component().getModel()).fireTableDataChanged(); selectFromDisplayGroup(); addAsListener(); // if we caused this change, do nothing if (pleaseIgnore) { pleaseIgnore = false; } else // otherwise, update the sort indicator { tableHeader.repaint(); // cancel any cell editing CellEditor editor = component().getCellEditor(); if (editor != null) { editor.cancelCellEditing(); } } } else if (source.selectionChanged()) { removeAsListener(); selectFromDisplayGroup(); addAsListener(); } } } private void selectFromDisplayGroup() { JTable component = component(); int index; component.getSelectionModel().clearSelection(); Enumeration e = source.selectionIndexes().objectEnumerator(); while (e.hasMoreElements()) { // add selections one-by-one to support non-contiguous index = ((Number) e.nextElement()).intValue(); component.getSelectionModel().addSelectionInterval(index, index); // adds one row } } // interface ListSelectionListener public void valueChanged(ListSelectionEvent e) { if (source != null) { if (selectionTracking || !e.getValueIsAdjusting()) { int[] selectedIndices = component().getSelectedRows(); final NSMutableArray indexList = new NSMutableArray(); for (int i = 0; i < selectedIndices.length; i++) { indexList.addObject(new Integer(selectedIndices[i])); } // invoke later so the component is repainted before // any potentially lengthy second-order effects happen: // this improves user-perceived responsiveness of big apps Runnable select = new Runnable() { public void run() { pleaseIgnore = true; source.setSelectionIndexes(indexList); } }; if (selectionPaintedImmediately) { EventQueue.invokeLater(select); } else { select.run(); } } } } /** * Determines whether the selection should be painted immediately after the user * clicks and therefore before the children display group is updated. When the * children group is bound to many associations or is bound to a master-detail * association, updating the display group can take a perceptibly long time. * This property defaults to false. * * @see #setSelectionPaintedImmediately */ public boolean isSelectionPaintedImmediately() { return selectionPaintedImmediately; } /** * Sets whether the selection should be painted immediately. Setting this * property to true will let the table paint first before the display group is * updated. */ public void setSelectionPaintedImmediately(boolean isImmediate) { selectionPaintedImmediately = isImmediate; } /** * Determines whether the selection is actively tracking the selection as the * user moves the mouse. If true, selection will not be updated while the list * selection event returns true for isValueAdjusting(). This property defaults * to false. * * @see #setSelectionTracking */ public boolean isSelectionTracking() { return selectionTracking; } /** * Sets whether the selection is actively tracking the selection as the user * moves the mouse. Setting this property to true will update the display group * with each change to the selection. This means that any tree selection listers * will also be notified before the display group is updated and will have to * invokeLater if they want to see the updated display group. */ public void setSelectionTracking(boolean isTracking) { selectionTracking = isTracking; } // convenience private JTable component() { return (JTable) object(); } /** * Copies the contents of the table to the clipboard as a tab-delimited string. */ public void copyToClipboard() { Toolkit toolkit = Toolkit.getDefaultToolkit(); Clipboard clipboard = toolkit.getSystemClipboard(); StringSelection selection = new StringSelection(getTabDelimitedString()); clipboard.setContents(selection, selection); } /** * Converts the contents of the table to a tab-delimited string. * * @return A String containing the text contents of the table. */ public String getTabDelimitedString() { StringBuffer result = new StringBuffer(64); TableModel model = component().getModel(); int cols = model.getColumnCount(); int rows = model.getRowCount(); Object o = null; for (int y = 0; y < rows; y++) { for (int x = 0; x < cols; x++) { o = model.getValueAt(y, x); if (o == null) o = ""; result.append(o); result.append('\t'); } result.append('\n'); } return result.toString(); } // interface ActionEventListener public void actionPerformed(ActionEvent evt) { String cmd = evt.getActionCommand(); if (COPY.equals(cmd)) { copyToClipboard(); return; } } /** * Used to render the little triangle over the sorted column(s). */ class SortedTableHeader extends JTableHeader { public void paint(Graphics g) { super.paint(g); Rectangle r; TableColumnAssociation association; int size = columns.size(); NSArray orderings; if (sortTarget != null) { orderings = sortTarget.sortOrderings(); } else { orderings = source.sortOrderings(); } for (int i = 0; i < size; i++) { r = getHeaderRect(component().convertColumnIndexToView(i)); association = (TableColumnAssociation) columns.objectAtIndex(i); association.drawSortIndicator(r, g, orderings); } } } /** * Used to listen for clicks on the table header. */ class TableHeaderListener extends MouseAdapter { public void mouseClicked(MouseEvent evt) { EODisplayGroup displayGroup = sortTarget; if (displayGroup == null) displayGroup = source; if (evt.getClickCount() > 0) { int columnClicked = tableHeader.columnAtPoint(evt.getPoint()); if (columnClicked != -1) { columnClicked = component().convertColumnIndexToModel(columnClicked); TableColumnAssociation association = (TableColumnAssociation) columns.objectAtIndex(columnClicked); if (association.isSortable()) { NSMutableArray newOrder = new NSMutableArray(displayGroup.sortOrderings()); // click once = asc, twice = desc, thrice = clear EOSortOrdering sortOrdering; int index = association.getIndexOfMatchingOrdering(newOrder); if (index == -1) sortOrdering = null; else if (index == 1) sortOrdering = association.getSortOrdering(false); else sortOrdering = association.getSortOrdering(true); pleaseIgnore = true; tableHeader.repaint(); // stop any cell editing CellEditor editor = component().getCellEditor(); if (editor != null) { editor.stopCellEditing(); } // remove existing key if (index != 0) { newOrder.removeObjectAtIndex(Math.abs(index) - 1); } // put new key at front of list if (sortOrdering != null) { newOrder.insertObjectAtIndex(sortOrdering, 0); } displayGroup.setSortOrderings(newOrder); displayGroup.updateDisplayedObjects(); } } } } } /** * Notifies of beginning of edit. */ public void focusGained(FocusEvent evt) { Object o; EODisplayGroup displayGroup; Enumeration e = aspects().objectEnumerator(); while (e.hasMoreElements()) { displayGroup = displayGroupForAspect(e.nextElement().toString()); if (displayGroup != null) { displayGroup.associationDidBeginEditing(this); } } } /** * Updates object on focus lost and notifies of end of edit. */ public void focusLost(FocusEvent evt) { if (!component().isEditing()) { Object o; EODisplayGroup displayGroup; Enumeration e = aspects().objectEnumerator(); while (e.hasMoreElements()) { displayGroup = displayGroupForAspect(e.nextElement().toString()); if (displayGroup != null) { displayGroup.associationDidEndEditing(this); } } } } /** * Used as the model for the controlled table. Package access so * TableColumnAssociation can recognize it and use the addColumnAssociation() * method. Extends AbstractTableModel just so we get event broadcasting for * free. */ static class TableAssociationModel extends AbstractTableModel { private TableAssociation parent; private TableAssociationModel(TableAssociation aParent) { parent = aParent; } public TableAssociation getAssociation() { return parent; } /** * Adds the column to the list of ColumnAssociations, and adds the corresponding * column to the table at the next available index, setting the value of the * column's model index accordingly. Establishes connection if no columns are * currently associated. */ public void addColumnAssociation(TableColumnAssociation aColumnAssociation) { // establish connection if necessary if (parent.columns.size() == 0) { parent.establishConnection(); } int newIndex = parent.columns.count(); parent.columns.add(aColumnAssociation); // add column to table TableColumn column = (TableColumn) aColumnAssociation.object(); column.setModelIndex(newIndex); parent.component().addColumn(column); } /** * Removes the column from the list of ColumnAssociations, and removes the * corresponding column from the table. Breaks connection if no more columns are * associated. */ public void removeColumnAssociation(TableColumnAssociation aColumnAssociation) { int index = parent.columns.indexOfIdenticalObject(aColumnAssociation); if (index == NSArray.NotFound) return; parent.columns.removeObjectAtIndex(index); // remove column from table TableColumn column = (TableColumn) aColumnAssociation.object(); parent.component().removeColumn(column); // break connection if necessary if (parent.columns.size() == 0) { parent.breakConnection(); } } public int getRowCount() { if (parent.source == null) return 0; return parent.source.displayedObjects().count(); } public int getColumnCount() { return parent.columns.count(); } /** * Attempts to retrieve the header value from the specified column, or returns " * " if the value is null. */ public String getColumnName(int columnIndex) { TableColumnAssociation association = (TableColumnAssociation) parent.columns.objectAtIndex(columnIndex); Object value = ((TableColumn) association.object()).getHeaderValue(); if (value != null) return value.toString(); return " "; } /** * Returns the class of the first item in the display group bound to the column. */ public Class getColumnClass(int columnIndex) { Object value; int count = getRowCount(); for (int i = 0; i < count; i++) { // First row in column is null find one that is not. value = ((TableColumnAssociation) parent.columns.objectAtIndex(columnIndex)).valueAtIndex(i); if (value != null) return value.getClass(); } return Object.class; } /** * Calls the column association's isEditableAtRow method. */ public boolean isCellEditable(int rowIndex, int columnIndex) { return ((TableColumnAssociation) parent.columns.objectAtIndex(columnIndex)).isEditableAtRow(rowIndex); } /** * Calls the column association's valueAtIndex method. */ public Object getValueAt(int rowIndex, int columnIndex) { return ((TableColumnAssociation) parent.columns.objectAtIndex(columnIndex)).valueAtIndex(rowIndex); } /** * Calls the column association's setValueAtIndex method. */ public void setValueAt(Object aValue, int rowIndex, int columnIndex) { Object existingValue = getValueAt(rowIndex, columnIndex); // don't update display group if value is the same as before if (aValue == existingValue) return; // handles null case if (existingValue != null) // both are not null { Object newValue = aValue; // if different types if (newValue.getClass() != existingValue.getClass()) { // convert to string since most editors do anyway newValue = newValue.toString(); existingValue = existingValue.toString(); } if (newValue.equals(existingValue)) { // same value - do nothing return; } } ((TableColumnAssociation) parent.columns.objectAtIndex(columnIndex)).setValueAtIndex(aValue, rowIndex); } } } /* * $Log$ Revision 1.2 2006/02/18 23:19:05 cgruber Update imports and maven * dependencies. * * Revision 1.1 2006/02/16 13:22:22 cgruber Check in all sources in * eclipse-friendly maven-enabled packages. * * Revision 1.31 2003/08/06 23:07:52 chochos general code cleanup (mostly, * removing unused imports) * * Revision 1.30 2002/11/16 16:33:31 mpowers Now using platform-specific * accelerator key for shortcuts. * * Revision 1.29 2002/05/24 14:41:29 mpowers Throwing an exception for clarity. * * Revision 1.28 2002/04/10 21:20:04 mpowers Better handling for tree nodes when * working with editing contexts. Better handling for invalidation. No longer * broadcasting events when nodes have not been "registered" in the tree. * * Revision 1.27 2002/03/05 23:18:28 mpowers Added documentation. Added * isSelectionPaintedImmediate and isSelectionTracking attributes to * TableAssociation. Added getTableAssociation to TableColumnAssociation. * * Revision 1.25 2002/03/04 22:49:53 mpowers Now working with * TreeColumnAssociation to delegate sorting behavior. * * Revision 1.23 2002/03/04 03:58:17 mpowers Refined table header click * behavior. * * Revision 1.22 2002/03/01 23:41:39 mpowers Added facility to get table * association from model. * * Revision 1.21 2002/03/01 20:41:22 mpowers Cleaned up unused code. * * Revision 1.20 2002/03/01 15:42:00 mpowers Table column headers now always * show their sort indicator. A third table-column click clears the sort for * that column. * * Revision 1.19 2002/02/28 23:01:39 mpowers TableColumnAssociations add and * remove themselves from the TableAssociation when their connection is * established and broken respectively. TableAssociations now break connection * if they have no column associations. * * Revision 1.18 2002/02/28 22:45:27 mpowers Now registers as editing * association when table gets focus, so that endEditing can appropriate commit * any table cell editors. * * Revision 1.17 2001/10/26 20:01:50 mpowers We're again cancelling instead of * stopping editing on subjectChanged. I believe that the introduction of * NSRunLoop has allowed us to return to the correct behavior. * * Revision 1.16 2001/09/14 13:40:26 mpowers User-initiated selection changes * are now handled on the next event loop so that the component repaints the new * selection before any potentially lengthy logic is triggered by the selection * change. * * Revision 1.15 2001/07/25 15:03:01 mpowers getColumnName now checks for a * column header value before defaulting " ". * * Revision 1.14 2001/06/05 19:09:25 mpowers Fixed broken build. * * Revision 1.13 2001/06/05 19:08:12 mpowers Better determination of column * class. Previously, if the first object was null, Object.class was used. Now * we get the first non-null object. * * Revision 1.12 2001/05/02 17:39:01 mpowers Now selects from display group * after initial population. * * Revision 1.11 2001/03/29 23:05:22 mpowers Fixes for editing table cells. * * Revision 1.10 2001/03/09 22:09:22 mpowers Now better handling jdk1.1 for * rendering the column header. * * Revision 1.9 2001/02/27 02:10:38 mpowers No longer updating values to the * display group if the value has not changed. * * Revision 1.8 2001/02/17 16:52:05 mpowers Changes in imports to support * building with jdk1.1 collections. * * Revision 1.7 2001/02/16 17:47:17 mpowers Now cancelling any cell editing on * subjectChanged(). * * Revision 1.6 2001/01/12 19:11:56 mpowers Fixed table column click sorting. * * Revision 1.5 2001/01/12 17:20:30 mpowers Moved EOSortOrdering creation to * ColumnAssociation. * * Revision 1.4 2001/01/11 21:55:57 mpowers Implemented sort indicator for table * column headers. * * Revision 1.3 2001/01/11 20:34:26 mpowers Implemented EOSortOrdering and added * support in framework. Added header-click to sort table columns. * * Revision 1.2 2001/01/08 20:40:51 mpowers JTables in 1.3 clear their selection * when the data model changes, which sends a list selection event. We now reset * the selection again after the data changes so we're compatible across 1.2 and * 1.3. * * Revision 1.1.1.1 2000/12/21 15:49:00 mpowers Contributing wotonomy. * * Revision 1.7 2000/12/20 16:25:41 michael Added log to all files. * * */