/*
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:
*
* - source: a property convertable to a string for
* display in the cells of the table column
* - enabled: a property convertable to a string for
* display in the cells of the table column.
* Note that you can bind this aspect to a key equal to
* "true" or "false" and leave the display group null.
*
*
* @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.
* - "A" attribute: the aspect can be bound to
* an attribute.
* - "1" to-one: the aspect can be bound to a
* property that returns a single object.
* - "M" to-one: the aspect can be bound to a
* property that returns multiple objects.
*
* 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.
*
*
*/