/*
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.Component;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.net.URL;
import java.text.Format;
import java.text.ParseException;
import java.util.Enumeration;
import java.util.Iterator;
import javax.swing.Icon;
import javax.swing.ImageIcon;
import javax.swing.JOptionPane;
import javax.swing.JTextArea;
import javax.swing.LookAndFeel;
import javax.swing.Timer;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.text.Document;
import javax.swing.text.JTextComponent;
import net.wotonomy.foundation.NSArray;
import net.wotonomy.foundation.NSSelector;
import net.wotonomy.foundation.internal.ValueConverter;
import net.wotonomy.foundation.internal.WotonomyException;
import net.wotonomy.ui.EOAssociation;
import net.wotonomy.ui.EODisplayGroup;
/**
* TimedTextAssociation works like TextAssociation,
* but instead of using a delayed event to update the
* model, it uses a timer so that the model is only
* updated if the user pauses typing for some short interval.
* This is useful when the update and/or re-read of the model
* is a costly operation.
* Bindings are:
*
* - value: a property convertable to/from a string
* - editable: a boolean property that determines whether
* the user can edit the text in the field
* - enabled: a boolean property that determines whether
* the user can select the text in the field
* - label: a boolean property that determines whether
* field should appear as a read-only, selectable label
* - icon: a property that returns a Swing icon, for use
* with JLabels and other components with setIcon() methods.
* If bound to a static string, the string will be used to
* load an image resource from the selected object's class.
*
*
* @author michael@mpowers.net
* @author $Author: cgruber $
* @version $Revision: 904 $
*/
public class TimedTextAssociation extends EOAssociation
implements FocusListener, ActionListener, DocumentListener
{
//TODO: need to refactor this so that it can subclass text association.
//This implementation is basically a branch from the v1.20 TextAssociation.
static final NSArray aspects =
new NSArray( new Object[] {
ValueAspect, EnabledAspect, EditableAspect, LabelAspect, IconAspect
} );
static final NSArray aspectSignatures =
new NSArray( new Object[] {
AttributeToOneAspectSignature,
AttributeToOneAspectSignature,
AttributeToOneAspectSignature,
AttributeToOneAspectSignature
} );
static final NSArray objectKeysTaken =
new NSArray( new Object[] {
"text", "enabled", "editable"
} );
private final static NSSelector getText =
new NSSelector( "getText" );
private final static NSSelector setText =
new NSSelector( "setText",
new Class[] { String.class } );
private final static NSSelector getDocument =
new NSSelector( "getDocument" );
private final static NSSelector setIcon =
new NSSelector( "setIcon",
new Class[] { Icon.class } );
private final static NSSelector addActionListener =
new NSSelector( "addActionListener",
new Class[] { ActionListener.class } );
private final static NSSelector removeActionListener =
new NSSelector( "removeActionListener",
new Class[] { ActionListener.class } );
private final static NSSelector addFocusListener =
new NSSelector( "addFocusListener",
new Class[] { FocusListener.class } );
private final static NSSelector removeFocusListener =
new NSSelector( "removeFocusListener",
new Class[] { FocusListener.class } );
// null handling
protected boolean wasNull;
protected static final String EMPTY_STRING = "";
// dirty handling
protected boolean needsUpdate;
protected boolean hasDocument;
protected boolean isListening;
// formatting
protected Format format;
// cache the value aspect
private EODisplayGroup valueDisplayGroup;
private String valueKey;
// coalescing document events
protected boolean autoUpdating;
protected int interval = 400; // adjust as needed
protected Timer keyTimer;
// NOTE: a new key timer is created for each use and
// is disposed when the timer is stopped.
// Swing's Timer class is kept in a static list of timers
// and each retains a strong reference to their listeners.
// This caused a memory leak as associations typically
// refer to their controlled component which is referred
// to by its parents and so on until no application window
// will ever get garbage collected. yikes.
/**
* Constructor specifying the object to be controlled by this
* association. Does not establish connection.
*/
public TimedTextAssociation ( Object anObject )
{
super( anObject );
wasNull = false;
needsUpdate = false;
hasDocument = false;
isListening = true;
valueDisplayGroup = null;
valueKey = null;
autoUpdating = true;
keyTimer = null;
}
/**
* 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 setText.implementedByObject( anObject );
}
/**
* 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 ( ValueAspect.equals( anAspect ) )
{
valueDisplayGroup = aDisplayGroup;
valueKey = aKey;
}
super.bindAspect( anAspect, aDisplayGroup, aKey );
}
/**
* Establishes a connection between this association
* and the controlled object. This implementation
* attempts to add this class as an ActionListener
* and as a FocusListener to the specified object.
*/
public void establishConnection ()
{
Object component = object();
try
{
if ( addActionListener.implementedByObject( component ) )
{
addActionListener.invoke( component, this );
}
if ( addFocusListener.implementedByObject( component ) )
{
addFocusListener.invoke( component, this );
}
hasDocument = false;
if ( getDocument.implementedByObject( component ) )
{
Object document = getDocument.invoke( component );
if ( document instanceof Document )
{
((Document)document).addDocumentListener( this );
hasDocument = true;
}
}
}
catch ( Exception exc )
{
throw new WotonomyException(
"Error while establishing connection", exc );
}
super.establishConnection();
// forces update from bindings
subjectChanged();
}
/**
* Breaks the connection between this association and
* its object. Override to stop listening for events
* from the object.
*/
public void breakConnection ()
{
Object component = object();
try
{
if ( removeActionListener.implementedByObject( component ) )
{
removeActionListener.invoke( component, this );
}
if ( removeFocusListener.implementedByObject( component ) )
{
removeFocusListener.invoke( component, this );
}
if ( getDocument.implementedByObject( component ) )
{
Object document = getDocument.invoke( component );
if ( document instanceof Document )
{
((Document)document).removeDocumentListener( this );
}
}
}
catch ( Exception exc )
{
throw new WotonomyException(
"Error while breaking connection", exc );
}
super.breakConnection();
}
/**
* Called when either the selection or the contents
* of an associated display group have changed.
*/
public void subjectChanged ()
{
Object component = object();
EODisplayGroup displayGroup;
String key;
Object value;
// value aspect
displayGroup = valueDisplayGroup;
if ( displayGroup != null )
{
if ( component instanceof Component )
{
((Component)component).setEnabled(
displayGroup.enabledToSetSelectedObjectValueForKey( valueKey ) );
}
key = valueKey;
if ( displayGroup.selectedObjects().size() > 1 )
{
// if there're more than one object selected, set
// the value to blank for all of them.
Object previousValue;
Iterator indexIterator = displayGroup.selectionIndexes().
iterator();
// get value for the first selected object.
int initialIndex = ( (Integer)indexIterator.next() ).intValue();
previousValue = displayGroup.valueForObjectAtIndex(
initialIndex, key );
value = null;
// go through the rest of the selected objects, compare each
// value with the previous one. continue comparing if two
// values are equal, break the while loop if they're different.
// the final value will be the common value of all selected objects
// if there is one, or be blank if there is not.
while ( indexIterator.hasNext() )
{
int index = ( (Integer)indexIterator.next() ).intValue();
Object currentValue = displayGroup.valueForObjectAtIndex(
index, key );
if ( currentValue != null && !currentValue.equals( previousValue ) )
{
value = null;
break;
}
else
{
// currentValue is the same as the previous one
value = currentValue;
}
} // end while
} else {
// if there's only one object selected.
value = displayGroup.selectedObjectValueForKey( key );
} // end checking the size of selected objects in displayGroup
// convert value to string
if ( value == null )
{
wasNull = true;
value = EMPTY_STRING;
}
else
{
wasNull = false;
if ( format() != null )
{
try
{
value = format().format( value );
}
catch ( IllegalArgumentException exc )
{
value = value.toString();
}
}
}
try
{
if ( ! value.toString().equals( getText.invoke( component ) ) )
{
// No need to listen for any events that might get fired
// while setting the text since we are the one setting it.
boolean wasListening = isListening;
isListening = false;
// setText is an expensive operation
setText.invoke( component, value.toString() );
isListening = wasListening;
needsUpdate = false;
}
}
catch ( Exception exc )
{
throw new WotonomyException(
"Error while updating component connection", exc );
}
}
// icon aspect
displayGroup = displayGroupForAspect( IconAspect );
key = displayGroupKeyForAspect( IconAspect );
if ( key != null )
{
if ( displayGroup != null )
{
value =
displayGroup.selectedObjectValueForKey( key );
}
else
{
// treat bound key without display group
// as a resource to be loaded from the selected class.
value = null;
Object o = displayGroup.selectedObject();
if ( o != null )
{
URL url = o.getClass().getResource( key );
if ( url != null )
{
value = new ImageIcon( url );
}
}
}
try
{
setIcon.invoke( component, value );
}
catch ( Exception exc )
{
throw new WotonomyException(
"Error while updating component connection", exc );
}
}
// enabled aspect
displayGroup = displayGroupForAspect( EnabledAspect );
key = displayGroupKeyForAspect( EnabledAspect );
if ( ( key != null )
&& ( component instanceof Component ) )
{
if ( displayGroup != null )
{
value =
displayGroup.selectedObjectValueForKey( key );
}
else
{
// treat bound key without display group as a value
value = key;
}
Boolean converted = null;
if ( value != null )
{
converted = (Boolean)
ValueConverter.convertObjectToClass(
value, Boolean.class );
}
if ( converted == null ) converted = Boolean.FALSE;
if ( ((Component)component).isEnabled() != converted.booleanValue() )
{
((Component)component).setEnabled( converted.booleanValue() );
}
}
// editable aspect
displayGroup = displayGroupForAspect( EditableAspect );
key = displayGroupKeyForAspect( EditableAspect );
if ( ( key != null )
&& ( component instanceof JTextComponent ) )
{
if ( displayGroup != null )
{
value =
displayGroup.selectedObjectValueForKey( key );
}
else
{
// treat bound key without display group as a value
value = key;
}
Boolean converted = (Boolean)
ValueConverter.convertObjectToClass(
value, Boolean.class );
if ( converted != null )
{
if ( converted.booleanValue() != ((JTextComponent)component).isEditable() )
{
((JTextComponent)component).setEditable( converted.booleanValue() );
}
}
}
// label aspect
displayGroup = displayGroupForAspect( LabelAspect );
key = displayGroupKeyForAspect( LabelAspect );
if ( ( key != null )
&& ( component instanceof JTextComponent ) )
{
if ( displayGroup != null )
{
value =
displayGroup.selectedObjectValueForKey( key );
}
else
{
// treat bound key without display group as a value
value = key;
}
Boolean converted = (Boolean)
ValueConverter.convertObjectToClass(
value, Boolean.class );
if ( converted != null )
{
if ( converted.booleanValue() )
{
if ( component instanceof JTextComponent )
{
if ( component instanceof JTextArea )
{
areaToLabel( (JTextArea) component );
}
else
{
fieldToLabel( (JTextComponent) component );
}
}
}
else
{
if ( component instanceof JTextComponent )
{
if ( component instanceof JTextArea )
{
labelToArea( (JTextArea) component );
}
else
{
labelToField( (JTextComponent ) component );
}
}
}
}
}
}
private void fieldToLabel( JTextComponent aTextField )
{
// turn on wrapping and disable editing and highlighting
aTextField.setEditable(false);
aTextField.setOpaque(false);
// Set the border, colors and font to that of a label
LookAndFeel.installBorder(aTextField, "Label.border");
LookAndFeel.installColorsAndFont(aTextField,
"Label.background",
"Label.foreground",
"Label.font");
}
private void labelToField( JTextComponent aTextField )
{
// turn on wrapping and disable editing and highlighting
aTextField.setEditable(true);
aTextField.setOpaque(true);
// Set the border, colors and font to that of a label
LookAndFeel.installBorder(aTextField, "TextField.border");
LookAndFeel.installColorsAndFont(aTextField,
"TextField.background",
"TextField.foreground",
"TextField.font");
}
private void areaToLabel( JTextArea aTextArea )
{
// turn on wrapping and disable editing and highlighting
aTextArea.setLineWrap(true);
aTextArea.setWrapStyleWord(true);
aTextArea.setEditable(false);
// Set the text area's border, colors and font to
// that of a label
LookAndFeel.installBorder(aTextArea, "Label.border");
LookAndFeel.installColorsAndFont(aTextArea,
"Label.background",
"Label.foreground",
"Label.font");
}
private void labelToArea( JTextArea aTextArea )
{
// turn on wrapping and disable editing and highlighting
aTextArea.setEditable(true);
// Set the border, colors and font to that of a label
LookAndFeel.installBorder(aTextArea, "TextArea.border");
LookAndFeel.installColorsAndFont(aTextArea,
"TextArea.background",
"TextArea.foreground",
"TextArea.font");
}
/**
* 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 ()
{
if ( keyTimer != null )
{
keyTimer.stop();
keyTimer.removeActionListener( this );
keyTimer = null;
}
return writeValueToDisplayGroup();
}
/**
* Writes the value currently in the component
* to the selected object in the display group
* bound to the value aspect.
* @return false if there were problems validating,
* or true to continue.
*/
protected boolean writeValueToDisplayGroup()
{
boolean returnValue = true;
if ( hasDocument && !needsUpdate ) return true;
EODisplayGroup displayGroup = valueDisplayGroup;
if ( displayGroup != null )
{
String key = valueKey;
Object component = object();
Object value = null;
try
{
//if ( getText.implementedByObject( component ) )
//{
value = getText.invoke( component );
//}
}
catch ( Exception exc )
{
throw new WotonomyException(
"Error updating display group", exc );
}
if ( ( wasNull ) && ( EMPTY_STRING.equals( value ) ) )
{
value = null;
}
else
if ( format() != null )
{
try
{
value = format().parseObject( value.toString() );
}
catch ( ParseException exc )
{
String message = exc.getMessage();
//"That format was not recognized.";
if ( displayGroup.associationFailedToValidateValue(
this, value.toString(), key, exc, message ) )
{
boolean wasListening = isListening;
isListening = false;
JOptionPane.showMessageDialog(
(Component)component, message );
isListening = wasListening;
}
needsUpdate = false;
return false;
}
}
needsUpdate = false;
// only update if the value is different from the one in the display group
Object existingValue = displayGroup.selectedObjectValueForKey( key );
if ( displayGroup.selectedObjects().size() == 1 )
{
if ( existingValue == value ) return true;
if ( ( existingValue != null ) && ( existingValue.equals( value ) ) ) return true;
if ( ( value != null ) && ( value.equals( existingValue ) ) ) return true;
}
// we might lose focus if display group displays a validation message
boolean wasListening = isListening;
isListening = false;
Iterator selectedIterator = displayGroup.selectionIndexes().iterator();
while ( selectedIterator.hasNext() )
{
int index = ( (Integer)selectedIterator.next() ).intValue();
if ( displayGroup.setValueForObjectAtIndex( value, index, key ) )
{
isListening = wasListening;
needsUpdate = false;
}
else
{
isListening = wasListening;
needsUpdate = false;
returnValue = false;
}
}
}
return returnValue;
}
/**
* Sets the Format that is used to convert values from the display
* group to and from text that is displayed in the component.
* Having a formatter disables auto-updating.
*/
public void setFormat( Format aFormat )
{
format = aFormat;
}
/**
* Gets the Format that is used to convert values from the display
* group to and from text that is displayed in the component.
*/
public Format format()
{
return format;
}
// interface ActionListener
/**
* Updates object on action performed.
*/
public void actionPerformed( ActionEvent evt )
{
if ( keyTimer != null )
{
keyTimer.stop();
keyTimer.removeActionListener( this );
keyTimer = null;
}
if ( ! isListening ) return;
if ( needsUpdate )
{
writeValueToDisplayGroup();
}
}
// interface FocusListener
/**
* Notifies of beginning of edit.
*/
public void focusGained(FocusEvent evt)
{
if ( ! isListening ) return;
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 ( ! isListening ) return;
if ( endEditing() )
{
Object o;
EODisplayGroup displayGroup;
Enumeration e = aspects().objectEnumerator();
while ( e.hasMoreElements() )
{
displayGroup =
displayGroupForAspect( e.nextElement().toString() );
if ( displayGroup != null )
{
displayGroup.associationDidEndEditing( this );
}
}
}
else
{
// probably should notify of a validation error here,
}
}
/**
* Returns whether the data model is updated for every change
* in the controlled component. If false, the data is only
* updated on focus lost or the enter key. Default is true.
*/
public boolean isAutoUpdating()
{
if ( format() != null ) return false;
return autoUpdating;
}
/**
* Sets whether the data model is updated for every change
* in the controlled component.
*/
public void setAutoUpdating( boolean isAutoUpdating )
{
autoUpdating = isAutoUpdating;
}
/**
* Triggers the key timer to start.
*/
protected void queueUpdate()
{
if ( isAutoUpdating() )
{
if ( keyTimer == null )
{
keyTimer = new Timer( interval, this );
}
keyTimer.restart();
}
}
// interface DocumentListener
public void insertUpdate(DocumentEvent e)
{
if ( ! isListening ) return;
needsUpdate = true;
queueUpdate();
}
public void removeUpdate(DocumentEvent e)
{
if ( ! isListening ) return;
needsUpdate = true;
queueUpdate();
}
public void changedUpdate(DocumentEvent e)
{
if ( ! isListening ) return;
needsUpdate = true;
queueUpdate();
}
}
/*
* $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.3 2004/01/28 18:34:57 mpowers
* Better handling for enabling.
* Now respecting enabledToSetSelectedObjectValueForKey from display group.
*
* Revision 1.2 2003/08/06 23:07:52 chochos
* general code cleanup (mostly, removing unused imports)
*
* Revision 1.1 2001/12/20 18:57:24 mpowers
* (Re-)Contributing TimedTextAssociation. Just like TA, except uses timers.
*
* Revision 1.20 2001/10/26 19:58:06 mpowers
* Better handling for non-string types. We were testing with equals with the
* new value against the existing value in the component. Now we convert
* the new value to a string before comparing. Fixes case for properties
* of non-String types, like StringBuffer.
*
* Revision 1.19 2001/09/30 21:57:14 mpowers
* Timers were not getting cleaned up if breakConnection was called
* before the timer got a chance to fire.
*
* Revision 1.18 2001/08/22 15:42:26 mpowers
* Added support for JTextComponent label-izing.
*
* Revision 1.17 2001/07/30 16:32:55 mpowers
* Implemented support for bulk-editing. Detail associations will now
* apply changes to all selected objects.
*
* Revision 1.16 2001/07/17 19:53:37 mpowers
* Made some private fields protected for benefit of subclassers.
*
* Revision 1.15 2001/06/30 14:59:36 mpowers
* LabelAspect now sets the text field's opaque setting.
*
* Revision 1.14 2001/06/29 14:54:08 mpowers
* Another fix for timers - timers were definitely causing a memory leak.
*
* Revision 1.13 2001/06/26 21:37:19 mpowers
* Fixed a null pointer in the new key timer scheme.
*
* Revision 1.12 2001/06/25 14:46:03 mpowers
* Fixed a memory leak involving the use of timers.
*
* Revision 1.11 2001/06/01 19:14:59 mpowers
* Text association's enabled aspect is now more discriminating.
*
* Revision 1.10 2001/05/18 21:07:24 mpowers
* Changed the way we handle failure to update object value.
*
* Revision 1.9 2001/03/13 21:39:58 mpowers
* Improved validation handling.
*
* Revision 1.8 2001/03/12 12:49:10 mpowers
* Improved validation handling.
* Having a formatter disables auto-updating.
*
* Revision 1.7 2001/03/09 22:08:13 mpowers
* Now handling any objects that have a valid Document.
* No longer checking enabled before updating the enabled state.
*
* Revision 1.6 2001/03/07 19:57:32 mpowers
* Fixed paste error in IconAspect.
*
* Revision 1.4 2001/02/17 16:52:05 mpowers
* Changes in imports to support building with jdk1.1 collections.
*
* Revision 1.3 2001/01/31 19:12:33 mpowers
* Implemented auto-updating in TextComponent.
*
* Revision 1.2 2001/01/10 15:53:58 mpowers
* Preventing a null pointer exception if getText were to return null,
* which doesn't happen for JTextFields but might happen for other objects.
*
* Revision 1.1.1.1 2000/12/21 15:49:08 mpowers
* Contributing wotonomy.
*
* Revision 1.13 2000/12/20 16:25:41 michael
* Added log to all files.
*
*
*/