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