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