/* 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. * * */