// (c) 2003 Allen I Holub. All rights reserved.
package com.holub.ui;
import com.holub.ui.NumericInput;// for testing
import java.util.logging.*;
import javax.swing.*;
import javax.swing.Timer; // disambiguate from java.util.Timer
import javax.swing.border.*;
import javax.swing.event.*;
import javax.swing.text.*;
import java.awt.*;
import java.awt.event.*;
import com.holub.tools.Log;
/* Demonstrate a JComponent-style proxy.
* <p>
* This class is
* a validating Text field. The first time you type something
* invalid, an tool tip pops up describing what correct
* input is. Subsequent errors just beep at you, but the
* tooltip will continue to pop up if the mouse hovers.
*
* <!-- ====================== distribution terms ===================== -->
* <p><blockquote
* style="border-style: solid; border-width:thin; padding: 1em 1em 1em 1em;">
* <center>
* Copyright © 2003, Allen I. Holub. All rights reserved.
* </center>
* <br>
* <br>
* This code is distributed under the terms of the
* <a href="http://www.gnu.org/licenses/gpl.html"
* >GNU Public License</a> (GPL)
* with the following ammendment to section 2.c:
* <p>
* As a requirement for distributing this code, your splash screen,
* about box, or equivalent must include an my name, copyright,
* <em>and URL</em>. An acceptable message would be:
* <center>
* This program contains Allen Holub's <em>XXX</em> utility.<br>
* (c) 2003 Allen I. Holub. All Rights Reserved.<br>
* http://www.holub.com<br>
* </center>
* If your progam does not run interactively, then the foregoing
* notice must appear in your documentation.
* </blockquote>
* <!-- =============================================================== -->
* @see DateInput
* @see NumericInput
* @author Allen I. Holub
*/
public class Input extends JTextField implements Styles
{
private final Customizer customizer;
private String lastValid; // Useful only in on-exit-style validation.
// holds the last-known valid contents
// of the control.
private boolean valid = true; // Used for communication between
private int offset; // the Document event handler and the
private int length; // UI Delegate update code.
private Popup popup = null; // Popup window used for "help,"
// is null unless window is visible.
private static int POPUP_LIFETIME = 15; // Maximum lifetime of popup
// window in seconds.
/** Provides information about, defines nonstandard initial
* state for, and validates user input.
* The predefined {@link NumericInput} class implements
* this interface for generic numbers, and the predefined
* {@link Input.Default} class implements it for
* a default, non-validating text control.
*/
public interface Customizer
{
/** Called by the Input object to determine whether validation
* is performed (by calling {@link #isValid}) after every
* character is entered or when the user hits enter (or the
* input object looses focus)
* <p>
* Note that on-exit validation causes validation to occur
* in two situations: the user hits Enter or the control
* looses focus. The Control won't let the focus change
* occur if the contents don't validate. However the Esc
* character is recognized as a reset-to-original-value
* request, so you can exit the field if you want to.
* The string ("Type Esc to exit") is automatically appended
* to the error message in this mode.
*
* @return false for character-by-character validation, true for
* one-time validation on loss of focus or Enter.
*/
boolean validatesOnExit();
/** Return true if the string is valid input. This method
* is called either on exiting the control or after every
* character is typed, depending on the return value of
* {@link #validatesOnExit}. Reguardless of the
* "validate-on-exit" mode, an hitting Esc alwasy resets
* the control to the last-known valid value.
* <p>
* This method is also called when the control is initialized,
* and the constructor will throw an exception if the initial
* value is not valid.
*
* @param s The entire contents of the control, including any
* characters the user just entered.
*/
boolean isValid( String s );
/** Return a description of what valid input looks likes.
* The string should be HTML, however the main context
* <html>, <head>, and <body> elements are already
* established, so these tags should not appear in
* your own text.
* @param badInput This is the string that the user tried to type.
* The control rejects the bad input, so this
* string is not displayed. You can put it into
* your error message if you like, however.
* @return a help string or null if no help is avilable.
*/
String help( String badInput );
/** Set up the look of the component for stuff not covered
* by the Style class. (For example, alignment and tool-tip
* text). This method after all initializations (including
* the text entry) have been made to the component.
* customizations have been made.
*/
void prepare( JTextField current );
}
/** An implemenation of Customizer that defines default behavior:
* All input is valid; there is no help; The prepare
* method makes the control 30 columns wide.
* You can extend this class if you just want
* to override one of the methods, much like an
* AWT <em>Xxx</em><code>Adapter</code>.
*/
static public class Default implements Customizer
{ public boolean isValid(String s){return true;}
public String help(String s) { return null; }
public void prepare( JTextField current )
{ current.setColumns(30);
}
public boolean validatesOnExit(){ return true; }
};
/** A constrained type to describe the border style you want.
* One of the predefined instances Input.BOXED, Input.UNDERLINED,
* or Input.BORDERLESS, must be passed to the
* {@linkplain Input#Input <code>Input</code> constructor}.
*/
public static final class BorderStyle{ private BorderStyle(){} }
public static final BorderStyle BORDERLESS = null;
public static final BorderStyle BOXED = new BorderStyle();
public static final BorderStyle UNDERLINED = new BorderStyle();
/** Construct a validating input field. Works like a JTextField,
* but checks the input as it's typed and complains
* if the input is invalid. Also implements the TagBehavior
* interface, so can be used in a PAC system as a stand-in
* for an <input> tag.
* <p>
* The default width of the control is determined by the width of the
* <code>value</code> string. You can change the width from the
* default by calling {@link javax.swing.JTextField#setColumns} after
* you create the object. Unlike the standard JTextField, the maximum
* and minimum widths are constrained to the initial size. (This
* constraint is required for the control to work correctly inside
* a {@link com.holub.ui.HTML.HtmlPane}, which is it's raison d'etre.)
*
* @param value initial value.
* @param customizer checks to see if the input is valid. A null argument
* is treated as if you had passed an {@link Default} object.
* @param border one of BORDERLESS, BOXED, or UNDERLINED.
* @param isHighlighted if true, highlight the text in the Style.HIGHLIGHT_COLOR
*
* @throws IllegalArgumentException if the customizer indicates that the
* initial <code>value</code> is invalid.
*/
public Input(String value, Customizer customizer, final BorderStyle border, final boolean isHighlighted)
{
this.customizer = (customizer != null)? customizer: new Default();
if( !customizer.isValid(value) )
throw new IllegalArgumentException("Customizer rejected initial value ["+ value +"]");
lastValid = value;
setFont( FONT );
if( isHighlighted )
setForeground( Color.RED ); // Affects border color too.
if( border==BOXED )
{ Border outer = BorderFactory.createLineBorder( Color.BLACK, 1 );
Border inner = BorderFactory.createEmptyBorder( 0, 4, 0, 4 );
setBorder( BorderFactory.createCompoundBorder(outer, inner) );
}
else if( border==UNDERLINED )
{ setBorder
( new AbstractBorder()
{ public void paintBorder(Component c, Graphics g,
int x, int y, int width, int height)
{ g.drawLine( x, y+height-1, x+width-1, y+height-1 );
}
}
);
}
else // BORDERLESS
{ setBorder( null ); // no border
}
setColumns( value.length() );
setText( value );
customizer.prepare( this );
// We do our own action-listener handling here, because
// notifications are sent on loss of focus as well as
// Enter. checkOnExit() validates the data, and
// notifies listeners if its valid.
super.addActionListener // handles Enter
( new ActionListener()
{ public void actionPerformed(ActionEvent e)
{ if( !validateInputAndNotifyListenersIfValid() )
retainFocus();
}
}
);
// Send action events on loss of focus as well as Enter, but
// only if the control holds valid input.
super.addFocusListener
( new FocusAdapter()
{ public void focusLost( FocusEvent e )
{ if( !validateInputAndNotifyListenersIfValid() )
retainFocus();
}
}
);
// Process an Esc to reset the field to its last known good
// value.
super.addKeyListener
( new KeyAdapter()
{ public void keyPressed( KeyEvent e )
{ if( e.getKeyCode() == KeyEvent.VK_ESCAPE )
{ Input.this.setText( lastValid );
}
}
}
);
// Set up a document listener. The documenation for JTextField
// uses an insertString override to map characters to upper case,
// but here, I want to disallow characters that will make the
// entire string invalid. Consequently, a different approach is
// required. The characterValidator is always installed because
// we need it to check for the Esc key. The action and focus
// listeners are added only if we need them because of
// exit validation.
if( !customizer.validatesOnExit() )
getDocument().addDocumentListener( characterValidator );
}
/** @return true if the data was valid and the listeners notified
*/
private boolean validateInputAndNotifyListenersIfValid()
{
String text = Input.this.getText();
valid = customizer.isValid( text );
if( valid )
{ lastValid = text;
fire_ActionEvent();
}
return valid;
}
private void retainFocus()
{ Input.this.requestFocus();
invalidate();
}
private final DocumentListener characterValidator = new CharacterObserver();
private class CharacterObserver implements DocumentListener
{ public void removeUpdate(DocumentEvent event){/*uninteresting*/}
public void changedUpdate(DocumentEvent event)
{ insertUpdate(event);
}
public void insertUpdate(DocumentEvent event)
{
offset = event.getOffset(); //used by paint
length = event.getLength(); //used by paint
// Document d = event.getDocument();
// String newText = d.getText(offset, length);
String fullText = Input.this.getText();
valid = Input.this.customizer.isValid( fullText );
if( !valid );
{
// You can't modify a Document in a
// DocumentListener, so force a repaint
// and update the valiator in paint(),
// which checks the "valid" flag, set
// set earlier.
invalidate();
}
}
}
/** Override of event dispatcher dismisses any popups when any
* event is detected.
*/
protected void processEvent( AWTEvent e )
{ super.processEvent(e);
if( e.getID()!=KeyEvent.KEY_RELEASED )
dismissPopups();
}
private void dismissPopups() // Not synchroinzed---must be called from
{
if( popup != null ) // AWT event thread.
{ popup.hide();
popup=null;
}
}
/** This method must be public because it's public in the base class. Don't
* override it.
*/
public void paint( Graphics g )
{ try
{ // Before you repaint, modify the document
// to eliminate any invalid characters detected by.
// the document listener
if( !valid )
{
explainTheProblem( getLocationOnScreen(), getText() );
getDocument().remove( offset, length );
Toolkit.getDefaultToolkit().beep();
valid = true;
}
super.paint(g);
}
catch( BadLocationException e)
{ Logger.getLogger("com.holub.PAC").warning
( Log.stackTraceAsString(e)
);
}
}
private void explainTheProblem( Point location, String badInput )
{
String help = customizer.help( badInput );
if( help != null && help.length() > 0 )
{ JLabel text = new JLabel();
text.setText
("<html><head>"
+"<style type=\"text/css\">"
+"body{ font: 11pt verdana, arial, helvetica, san-serif; background: #ffffcc}"
+"</style>"
+"</head><body>"
+"<table border=0 cellspacing=0 cellpadding=6><tr><td>"
+ help
+ (customizer.validatesOnExit()
? "<p>Press Esc to reset this field." : "")
+"</td></tr></table></body></html>"
);
// Move the location a bit so that it doesn't completely
// obscure the control.
location.translate(getWidth() * 1/2, getHeight() * 1/2);
popup = PopupFactory.getSharedInstance().getPopup( this, text,
location.x, location.y );
popup.show();
// Create a timer to kill the popup after POPUP_LIFETIME
// seconds of user inactivity
// Must be an javax.swing.Timer timer so that dismissPopups() will
// work correctly.
Timer t=new Timer( POPUP_LIFETIME * 1000,
new ActionListener()
{ public void actionPerformed(ActionEvent evt)
{ dismissPopups();
}
}
);
t.setRepeats(false);
t.start();
}
}
private ActionListener listeners = null;
/************************************************************************
* Action listeners are notified when the control holds valid
* input and the user is done entering data. This will happen
* in two situations:
* <ol>
* <li> The user hit Enter and the contents
* are properly validated.
* <li>When a control looses focus,
* and the control holds a valid string. (It's actually not possible
* for the control to loose focus when the contents aren't valid,
* and a notification <em>is not sent</em> if the user exits
* the control by hitting Esc.). The {@link ActionEvent ActionEvent}'s
* "command" string holds the user input as would be returned from
* <code>toString()</code>.
* </ol>
* @param l
*/
public synchronized void addActionListener(ActionListener l)
{ listeners = AWTEventMulticaster.add(listeners, l);
}
/** Remove a listener added by a prior
* {@link #addActionListener addActionListener(...)} call.
*/
public synchronized void removeActionListener(ActionListener l)
{ listeners = AWTEventMulticaster.remove(listeners, l);
}
public void fire_ActionEvent()
{ if( listeners != null )
{ ActionEvent e = new ActionEvent( this, 0, toString() );
listeners.actionPerformed(e);
}
}
//----------------------------------------------------------------------
// Misc short methods.
public Dimension getMinimumSize() { return getPreferredSize(); }
public Dimension getMaximumSize() { return getPreferredSize(); }
public String toString() { return getText(); }
/************************************************************************
* A test class.
*/
public static class Test
{ public static void main( String[] args )
{
Customizer weird =
new Customizer()
{ public boolean validatesOnExit(){ return true; }
public boolean isValid( String s )
{ System.out.println("Weird validating: [" + s + "]" );
return s.length() == 0;
}
public String help( String badInput )
{ return "Only empty strings are valid";
}
public void prepare( JTextField current )
{ current.setToolTipText("Only empty strings are valid");
}
};
final Input s1 = new Input( "", weird, BOXED, false );
s1.setColumns(10);
Customizer integer = new NumericInput.Behavior(-100, 100, 0);
Customizer money = new NumericInput.Behavior(0, 10000,2);
Customizer plain = new NumericInput.Behavior();
final Input n1 = new Input( "99", integer, UNDERLINED, true );
final Input n2 = new Input( "123.00", money , BOXED, false);
final Input n3 = new Input( "1,234.567", plain, BORDERLESS, false);
ActionListener reporter =
new ActionListener()
{ public void actionPerformed( ActionEvent e )
{ System.out.println("--------------------");
System.out.println( "n1=" + n1.getText() );
System.out.println( "n2=" + n2.getText() );
System.out.println( "n3=" + n3.getText() );
}
};
n1.addActionListener( reporter );
n2.addActionListener( reporter );
n3.addActionListener( reporter );
try // check that validation occurs on initialization
{ Input x = new Input( "xxx", plain, BOXED, true );
System.out.println("Initialization validation Failed");
}
catch( IllegalArgumentException e )
{ System.out.println("Initialization validation OK");
}
n1.setColumns(5);
JPanel panel = new JPanel();
panel.setLayout( new FlowLayout(FlowLayout.CENTER, 10, 10) );
panel.setBackground( Color.WHITE );
panel.add(n1);
panel.add(n2);
panel.add(n3);
panel.add(s1);
JFrame frame = new JFrame();
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.getContentPane().setBackground( Color.WHITE );
frame.getContentPane().setLayout( new BorderLayout() );
frame.getContentPane().add(panel, BorderLayout.SOUTH );
frame.pack();
frame.show();
frame.addWindowListener
( new WindowAdapter()
{ public void windowClosing( WindowEvent e )
{ System.out.println( "n1=" + n1.getText() );
System.out.println( "n2=" + n2.getText() );
System.out.println( "n3=" + n3.getText() );
System.exit(0);
}
}
);
}
}
}