HomeDigital EditionSys-Con RadioSearch Java Cd
Advanced Java AWT Book Reviews/Excerpts Client Server Corba Editorials Embedded Java Enterprise Java IDE's Industry Watch Integration Interviews Java Applet Java & Databases Java & Web Services Java Fundamentals Java Native Interface Java Servlets Java Beans J2ME Libraries .NET Object Orientation Observations/IMHO Product Reviews Scalability & Performance Security Server Side Source Code Straight Talking Swing Threads Using Java with others Wireless XML
 

When you write user interfaces, you inevitably have to collect information from text fields and validate the data before you use it. There are several ways of handling validation. You can verify the text as the user exits the field by watching for lost focus events, or you can wait for the user to dismiss a window or dialog box by pressing a button, thereby validating all the fields at once. Both approaches are useful, but they can also lead to complex scenarios. Providing appropriate feedback and cursor positioning when invalid data is entered can often become complicated. Often, a better approach is to validate the information as it's being entered by the user - a technique known as keystroke validation.

This month's JMaskField goes much further than restricting the user to valid keystrokes by supporting the following advanced features:

  • Customizable Rules: We use a regular expression-style syntax to define rules that determine whether a given character is acceptable for each position in the field. A mask can be defined with a mix of literal characters and validation rules.
  • Macro Characters: We can make it possible to associate any character with an expression in order to support a more compact notation. For example, "#" might be assigned to an expression like "[0-9]" to represent numerical values.
  • Cursor Positioning: The cursor is positioned intelligently after keystrokes, skipping literal characters that don't need to be typed in by the user.
  • Template Character: You can define any character as a visual cue to indicate positions where the user hasn't yet typed or where a character was deleted. The default template character is the underscore ("_").
Figure 1 shows the classes we'll be developing and how they relate to each other. While this may seem like a lot, you can see by the diagram that most of the classes address tokenizing and parsing the mask. Once parsed, the validation is pretty straightforward and is handled by our extension to the PlainDocument class. JMaskField extends JTextField and merely adds some cursor movement code to make it more user-friendly.

Figure 1
Figure 1:

JMaskField provides the high-level interface you'll use in your applications. In practice, you can provide a mask and template character in the JMaskField constructor and check the field output for template characters to determine if they were fully entered by the user at runtime.

Tokenizing Masks
To process the field mask we need to tokenize the text and build a parse tree from the token list. The parse tree elements are used to match the characters as they're being typed by the user. The Java class library includes two out-of-the-box tokenizers: StringTokenizer and StreamTokenizer. While these are useful, they just don't provide sufficient information when telling the user where the problems have occured. So we'll create a more flexible solution.

The MaskTokenizer produces a list of MaskToken elements. And, this MaskToken class, as seen in Listing 1, has two member variables: pos, an integer value that stores the offset from the beginning of the tokenized text, and text, a string value that stores the actual token. Because many tokens are single characters, we overload the equals method to handle both string and char inputs. This makes parsing easier when we need to determine what type of token we're dealing with.

Listing 2 shows the MaskTokenizer class, which has a single constructor that requires two arguments: an include string that identifies all delimiter characters which should be returned as tokens, and an exclude string that identifies all delimiter characters which shouldn't be returned as tokens. A math tokenizer, for example, might use an include string like "+-*/"and an exclude string like " ". This would return any nonspace sequence and consider the math operators separate tokens.

The MaskTokenizer provides hasMoreTokens and nextToken methods, just like the Java tokenizers, but nextToken returns a MaskToken instance. In addition, we provide an ignoreToken method to push back the current position after reading a token. This is useful with most parsers, which sometimes need to look ahead before determining whether the next token is relevant.

Regular Expressions
Regular expressions are used heavily in languages like Perl and AWK, and are often applied by programmers either through command line searching with the GREP utility or in flexible search/find capabilities exposed in modern user interfaces. While these are often considered too complicated for average computer users, they fit quite nicely into the savvy programmer's bag of tricks.

Table 1 lists the characters we're interested in. Regular expressions have a couple of reserved characters that represent the beginning and the end of a line, as well as sequence modifiers. These aren't directly applicable to our solution, so they're not included in this table. If you're familiar with regular expressions, you'll notice that we've changed the character for NOT operators. This is nothing more than artistic license. Naturally, if you prefer something else in your application, you're free to change it to whatever you like.

Table 1

We'll take a look at the syntax in a moment. In the JMaskField widget, a mask is provided in the form of a string. The string can be a mix of literal characters - which may not be edited - and rules expressed using our regular expressions subset. To distinguish between rules and literals, we delimit rules with curly braces. Table 2 shows a few valid masks with a brief explanation for each.

Table 2

The last example shows how macros can be used to make this syntax more compact. Let's take a quick look at the parser.

Parsing the Rules
Once the text has been tokenized, we can organize it by constructing a parse tree. When errors are encountered, we throw a MaskException (as shown in Listing 3) to tell the caller what was expected and the text position where the error occurred. This makes it a lot easier to deal with syntax errors before they become problems. The text offset position is especially informative and makes addressing any occurring problems much easier.

The rule parser uses several supporting classes to represent the resulting item list. Each of these implements the MaskElement interface from Listing 4, which enforces the use of a toString and a match method. The toString method is useful for debugging, so we can see the structure by just writing it out. The match method tests a character for validity and will get used in the document class we're implementing later.

Here's a quick look at the syntax using the BNF format:

<element> ::= '{' <condition> '}' |
<literal>
<condition> ::= <expression> [ <conjunction> <condition> ] <conjunction> ::= '&' |
'|'
<expression> ::= '(' <expression> ')' |
<character-set>
<character-set> ::= '[' [ '!' ]
<characters-list> ']'

BNF allows us to represent the syntax in a manner which is very close to the way we need to program the parser. The productions above can be easily described in English. Each production involves an element on the left and options on the right. In BNF the options are separated by a "|" character and may include optional elements that are delimited by square brackets. Thus the first production means an element is either a condition (delimited by curly braces) or a literal.

The second production means a condition is an expression, followed by an optional conjunction and another condition. The conjunctions are either the or ("|") character or the and ("&") character. The expression production is there primarily to allow parenthesis-delimited nesting - exactly the way mathematical expressions can be given precedence by wrapping them in parentheses. If no parenthesis is present, we expect a character set.

We consider a character set to be a special case in our parser by expecting a set to be delimited by square brackets and to be optionally negated, using the "!" modifier. Character ranges aren't handled as tokenized elements. It's easier to consider anything tokenized as a single character sequence and to process sets with the parser. When we run into a set, we traverse the characters and handle dash ("-") delimited character pairs by dynamically expanding them so that the resulting set is explicit. This makes later matching more efficient.

Listing 5 shows the MaskLiteral class, which represents literals and stores the character internally. The match method simply does a direct comparison with the test character. Macro characters are considered literals until they're interpreted at runtime. This allows us to change the macro definitions without requiring the mask to be parsed again, thereby increasing our flexibility.

The MaskSet class is shown in Listing 6. A set of characters is made explicit by the parser, expanding hyphenated ranges into a string list. The parser automatically handles inverted ranges if the value of the rightmost character is less than the value of the leftmost character. The only additional information required in our MaskSet representation is the negation marker if a NOT operator was used and stored as a Boolean value.

Listing 7 shows the MaskExpression class. Expressions are just wrappers designed to handle precedence. The match method calls the encapsulated MaskElement at comparison time. MaskCondition is more interesting. Listing 8 shows how it stores a Boolean value to indicate whether an AND or an OR conjunction is used. We keep a couple of constants around to make the parser code more readable. The match method uses the Java logical and ("&") and or ("|") operators to resolve the function. The left and right arguments are resolved by calling their own match methods.

The MaskParser, shown in Listing 9, is implemented as a separate class so it can be used by both the MaskMacros and the MaskDocument classes. There isn't enough room here to say much about recursive descent parsers, other than the fact that they operate much as the name implies. They use recursion to build a tree structure and descend to parse any nested structures. As mentioned earlier, the structure of the methods in MaskParser closely resembles the structure of the BNF notation used to represent the syntax.

Extending the Model
One of the objectives I had when I decided to implement the JMaskField control was to make it both powerful and easy to use. This is one of those standard programming dichotomies that's difficult to resolve and requires some thought. The best option I was able to identify was to make the syntax for defining rules ultimately flexible. That makes it powerful, providing a mechanism for abstracting those rules in simple form. This mechanism is implemented in the form of character macros.

Figure 2 shows how a simple mask gets expanded to a parsed expression, and finally to explicit character sets.

Figure 2
Figure 2:

Listing 10 shows how the MaskMacro class is really little more than a hashtable that stores an association between a given character and a MaskElement representing the rule(s) to be applied. When the MaskDocument in Listing 11 runs into a literal character, it checks to see if there's a rule associated with it in the MaskMacro model. While it's always possible to define masks using the curly brace syntax, you can see that it's much easier to define your own rules and assign them to macro characters.

The Document Model
The MaskDocument class extends the PlainDocument class in the JFC and can be assigned to any JTextComponent. The JMaskField class is presented in Listing 12 and extends JTextField, implementing additional behavior to handle intelligent cursor movement. Let's take a quick look at the MaskDocument class before we cover the JMaskField code.

To provide visual feedback, we generate a template for the mask expression. To keep things simple for the user, we'll use an underscore as a placeholder for nonliteral characters. The underscore is the default template character, but you can easily change it if you prefer something else. Figure 3 shows how the mask presentation and user input parallel each other.

Figure 3
Figure 3:

The MaskDocument class implements supporting methods to handle the template and to make character matching easier, but it primarily implements the remove and insertString methods required by the JFC Document interface. The Document interface has two methods we have to override in order to get the behavior we need.

The insertString method is called any time new data is entered in the text field, typically after every keystroke. The remove method is called whenever text is deleted.

The insertString and remove methods can pass several characters at the same time, as would be the case in cut or paste operations. To support these, we break each string into single characters and handle them recursively. The net effect is that a parse operation may not complete if some of the characters don't match, but the field will still handle as many characters as possible. Each character, processed individually, is tested by the match method and allowed to replace template characters in the insertString method. A character is replaced by a template character when being deleted in the remove method.

Summary
Figure 4 shows JMaskField in action. The Phone and Postal Code fields have already been entered and the other fields demonstrate a mix of literal and template characters that provide visual cues to guide the user through a successful experience. When inappropriate characters are typed in, the user hears a beep and the character is rejected.

Figure 4
Figure 4:

JMaskField provides a flexible mechanism for constraining character entries in a text field. Because it uses standard Java strings, any valid character pattern can be applied to define the data mask. The simple, regular, expressionlike syntax lets you define arbitrary character rules. Extending this model to support character macros adds even more flexibility and the template view provides useful feedback for the user. Together, these elements provide you with yet another tool to make the user experience as pleasant as possible. Use it in good health.

About the Author
Claude Duguay has been programming since 1980. In 1988 he founded LogiCraft Corporation, and he currently leads the development team at Atrieva Corp. You can contact him with questions and comments at [email protected]

	

Listing 1.
 
public class MaskToken { protected int pos; protected String text; 
public MaskToken(int pos, String text) 
{ this.pos = pos; this.text = text; } 
public boolean equals(char chr) 
{ if (text.length() == 1) return chr == text.charAt(0); 
else return false; } 
public boolean equals(String test) 
{ return text.equals(test); } public String toString() 
{ return "token(" + pos + "," + '"' + text + "' + ")"; } }


Listing 2.

public class MaskTokenizer
{
  protected Vector tokens = new Vector();
  protected String include, exclude;
  protected int pos = 0;

  public MaskTokenizer(String include, String exclude)
  {
    this.include = include;
    this.exclude = exclude;
  }

  public void tokenize(String text)
  {
    int prev = 0;
    tokens.removeAllElements();
    StringBuffer buffer = new StringBuffer();
    for (int i = 0; i < text.length(); i++)
    {
      if (include.indexOf(text.charAt(i)) > -1)
      {
        if (buffer.length() > 0) tokens.addElement(
          new MaskToken(prev, buffer.toString()));
        tokens.addElement(
          new MaskToken(i, "" + text.charAt(i)));
        buffer.setLength(0);
        prev = i + 1;
      }
      else if (exclude.indexOf(text.charAt(i)) > -1)
      {
        if (buffer.length() > 0) tokens.addElement(
          new MaskToken(prev, buffer.toString()));
        buffer.setLength(0);
        prev = i + 1;
      }
      else buffer.append(text.charAt(i));
    }
    if (buffer.length() > 0) tokens.addElement(
      new MaskToken(prev, buffer.toString()));
  }

  public boolean hasMoreTokens()
  {
    return pos < tokens.size();
  }

  public MaskToken nextToken()
  {
    return (MaskToken)tokens.elementAt(pos++);
  }

  public void ignoreToken()
  {
    if (pos > 0) pos--;
  }
}

Listing 3.

public class MaskException extends RuntimeException
{
  public MaskException(String description)
  {
    super(description);
  }
}

Listing 4.

public interface MaskElement
{
  public String toString();
  public boolean match(char chr);
}

Listing 5.

public class MaskLiteral implements MaskElement
{
  protected char chr;

  public MaskLiteral(char chr)
  {
    this.chr = chr;
  }

  public String toString()
  {
    return "literal('" + chr + "')";
  }

  public boolean match(char chr)
  {
    return this.chr == chr;
  }
}

Listing 6.

public class MaskSet implements MaskElement
{
  protected boolean negate;
  protected String set;

  public MaskSet(boolean negate, String set)
  {
    this.negate = negate;
    this.set = set;
  }

  public String toString()
  {
    return (negate ? "not(" : "set(") + set +
")";
  }

  public boolean match(char chr)
  {
    boolean member = set.indexOf(chr) > -1;
    if (negate) return !member;
    else return member;
  }
}

Listing 7.

public class MaskExpression implements MaskElement
{
  protected MaskElement element;

  public MaskExpression(MaskElement element)
  {
    this.element = element;
  }

  public String toString()
  {
    return "expression(" + element.toString() + ")";
  }

  public boolean match(char chr)
  {
    return element.match(chr);
  }
}

Listing 8.

public class MaskCondition implements MaskElement
{
  public static final boolean AND = true;
  public static final boolean OR = false;

  protected boolean and;
  protected MaskElement left, right;

  public MaskCondition(boolean and,
    MaskElement left, MaskElement right)
  {
    this.and = and;
    this.left = left;
    this.right = right;
  }

  public String toString()
  {
    return "rule(" + left.toString() +
      (and ? " and " : " or ") +
      right.toString() + ")";
  }

  public boolean match(char chr)
  {
    if (and) return left.match(chr) && right.match(chr);
    else return left.match(chr) || right.match(chr);
  }
}

Listing 9.

public class MaskParser
{
  public MaskElement parseMacro(String text)
  {
    MaskTokenizer tokenizer =
      new MaskTokenizer("&|![]() ", "");
    tokenizer.tokenize(text);
    return parseCondition(tokenizer);
  }

  public MaskElement parseCondition(MaskTokenizer tokenizer)
  {
    MaskElement node = parseExpression(tokenizer);
    if (tokenizer.hasMoreTokens())
    {
      MaskToken next = tokenizer.nextToken();
      if (next.equals('|'))
      {
        return new MaskCondition(MaskCondition.OR,
          node, parseCondition(tokenizer));
      }
      if (next.equals('&'))
      {
        return new MaskCondition(MaskCondition.AND,
          node, parseCondition(tokenizer));
      }
    }
    tokenizer.ignoreToken();
    return node;
  }

  private MaskElement parseExpression(MaskTokenizer tokenizer)
  {
    MaskToken token = tokenizer.nextToken();
    if (token.equals('('))
    {
      MaskElement node = parseCondition(tokenizer);
      expect(tokenizer.nextToken(), ')');
      return new MaskExpression(node);
    }
    tokenizer.ignoreToken();
    return parseSet(tokenizer);
  }

  private MaskElement parseSet(MaskTokenizer tokenizer)
  {
    expect(tokenizer.nextToken(), '[');
    MaskToken token = tokenizer.nextToken();
    boolean negate = token.equals('!');
    if (negate) token = tokenizer.nextToken();
    expect(tokenizer.nextToken(), ']');
    return new MaskSet(negate, expandSet(token.text));
  }

  private String expandSet(String text)
  {
    int i = 0;
    StringBuffer buffer = new StringBuffer();
    while (i < text.length())
    {
      if (i < text.length() - 2 && text.charAt(i + 1) == '-')
      {
        int from = (int)text.charAt(i);
        int to = (int)text.charAt(i + 2);
        if (from > to)
        {
          int temp = from;
          from = to;
          to = temp;
        }
        for (int c = from; c <= to; c++)
        {
          buffer.append((char)c);
        }
        i += 3;
      }
      else
      {
        buffer.append(text.charAt(i));
        i++;
      }
    }
    return buffer.toString();
  }

  public static void expect(MaskToken token, char chr)
  {
    if (!token.equals(chr)) throw new MaskException(
      "Syntax error: '" + chr + "' expected at " +
token.pos);
  }
}

Listing 10.

public class MaskMacros
{
  protected Hashtable table;
  protected MaskParser parser = new MaskParser();

  public MaskMacros()
  {
    table = new Hashtable();
  }

  public void addMacro(char key, String macro)
  {
    MaskElement element = parser.parseMacro(macro);
    table.put(new Character(key), element);
  }

  public void removeMacro(char key)
  {
    table.remove(new Character(key));
  }

  public MaskElement getMacro(char key)
  {
    return (MaskElement)table.get(new Character(key));
  }

  public boolean containsMacro(char key)
  {
    return table.containsKey(new Character(key));
  }

  public String toString()
  {
    StringBuffer buffer = new StringBuffer();
    buffer.append("macros\n{\n");
    Enumeration keys = table.keys();
    Enumeration enum = table.elements();
    Character key;
    MaskElement element;
    while (keys.hasMoreElements())
    {
      key = (Character)keys.nextElement();
      element = (MaskElement)enum.nextElement();
      buffer.append(" " + key.charValue() + "=");
      buffer.append(element.toString() + "\n");
    }
    buffer.append("}\n");
    return buffer.toString();
  }
}

Listing 11.

public class MaskDocument extends PlainDocument
{
  protected char templateChar;
  protected MaskMacros macros;
  protected MaskTokenizer tokenizer;
  protected Vector pattern = new Vector();
  protected MaskParser parser = new MaskParser();

  public MaskDocument(String mask,
    MaskMacros macros, char templateChar)
  {
    this.templateChar = templateChar;
    this.macros = macros;
    parse(mask);
  }

  public void parse(String text)
  {
    MaskTokenizer tokenizer =
      new MaskTokenizer("&|![](){} ", "");
    pattern.removeAllElements();
    tokenizer.tokenize(text);
    while (tokenizer.hasMoreTokens())
    {
      parseElement(tokenizer);
    }
  }

  private void parseElement(MaskTokenizer tokenizer)
  {
    MaskToken next = tokenizer.nextToken();
    if (next.equals('{'))
    {
      pattern.addElement(parser.parseCondition(tokenizer));
      MaskParser.expect(tokenizer.nextToken(), '}');
    }
    else
    {
      String text = next.text;
      for (int i = 0; i < text.length(); i++)
      {
        pattern.addElement(new MaskLiteral(text.charAt(i)));
      }
    }
  }

  public MaskElement getRule(int index)
  {
    return (MaskElement)pattern.elementAt(index);
  }

  public String template()
  {
    int length = pattern.size();
    StringBuffer buffer = new StringBuffer();
    for (int i = 0; i < length; i++)
    {

     buffer.append(template(i));
    }
    return buffer.toString();
  }

  public char template(int pos)
  {
    MaskElement rule = getRule(pos);
    if (rule instanceof MaskLiteral)
    {
      char literal = ((MaskLiteral)rule).chr;
      if (!macros.containsMacro(literal))
      return literal;
    }
    return templateChar;
  }

  public boolean match(int pos, char chr)
  {
   MaskElement element = getRule(pos);
    if (element instanceof MaskLiteral)
    {
      char macro = ((MaskLiteral)element).chr;
      if (macros.containsMacro(macro))
      {
        return macros.getMacro(macro).match(chr);
      }
    }
    return element.match(chr);
  }

  public void insertString(int pos, String text,
    AttributeSet attr) throws BadLocationException
  {
    int len = text.length();
    if (len == 0) return;
    if (len > 1)
    {
     for (int i = pos; i < len; i++)
        insertString(pos, "" + text.charAt(i), attr);
      return;
    }
    else
    {
      if (match(pos, text.charAt(0)))
      {
        super.remove(pos, 1);
        super.insertString(pos, text, attr);
      }
      else
      {
        Toolkit.getDefaultToolkit().beep();
        return;
      }
    }
  }

  public void remove(int pos, int length)
    throws BadLocationException
  {
    if (length > 1)
    {
      for (int i = pos; i < length; i++)
       remove(pos, 1);
      return;
    }
    else
    {
      if (length == 0 && getLength() == 0)
      {
        String template = template();
        super.insertString(pos, template, null);
        return;
      }
      if (pos == getLength()) return;

      String text = "" + template(pos);
      super.remove(pos, 1);
      super.insertString(pos, text, null);
    }
  }
}

Listing 12.

public class JMaskField extends JTextField implements
  DocumentListener, KeyListener, FocusListener
{
  protected MaskMacros macros;
  protected MaskDocument doc;
  protected boolean bspace;
  protected boolean delete;
  protected int pos = -1;

  public JMaskField(String mask)
  {
    this(mask, new MaskMacros(), '_');
  }

  public JMaskField(String mask, MaskMacros macros)
  {
    this(mask, macros, '_');
  }

  public JMaskField(String mask,
    MaskMacros macros, char templateChar)
  {
    setMacros(macros);
    doc = new MaskDocument(mask, macros, templateChar);
    doc.addDocumentListener(this);
    addFocusListener(this);
    addKeyListener(this);
    setDocument(doc);
    setText("");
    setPreferredSize(new Dimension(128, 23));
  }

  public MaskMacros getMacros()
  {
    return macros;
  }

  public void setMacros(MaskMacros macros)
  {
    this.macros = macros;
  }

  private void adjustCaretForward(int pos)
  {
    while (isLiteral(pos)) {pos++;}
    if (pos > doc.getLength()) pos = doc.getLength();
    setCaretPosition(pos);
  }

  private void adjustCaretBackward(int pos)
  {
    while (isLiteral(pos - 1)) {pos--;}
    if (pos <= 0)
    {
      adjustCaretForward(0);
    }
    else setCaretPosition(pos);
  }

 private boolean isLiteral(int pos)
  {
    if (pos < 0 || pos >= doc.pattern.size())
      return false;
    MaskElement rule = doc.getRule(pos);
    if (rule instanceof MaskLiteral)
    {
      char literal = (((MaskLiteral)rule).chr);
      return !doc.macros.containsMacro(literal);
    }
    return false;
  }

  public void focusLost(FocusEvent event)
  {
    pos = getCaretPosition();
  }

  public void focusGained(FocusEvent event)
  {
    if (pos < 0) adjustCaretForward(0);
    else setCaretPosition(pos);
  }

  public void keyTyped(KeyEvent event) {}
  public void keyReleased(KeyEvent event) {}
  public void keyPressed(KeyEvent event)
  {
    bspace = event.getKeyCode() == KeyEvent.VK_BACK_SPACE;
    delete = event.getKeyCode() == KeyEvent.VK_DELETE;
  }

  public void changedUpdate(DocumentEvent event) {}
  public void removeUpdate(DocumentEvent event) {}
  public void insertUpdate(DocumentEvent event)
  {
    int pos = event.getOffset();
    int len = event.getLength();
    if (bspace) adjustCaretBackward(pos);
    else if (delete) setCaretPosition(pos);
    else adjustCaretForward(pos + 1);
  }
}


  
      

Download Assoicated Source Files (Zip format - 23.1 KB)
 

All Rights Reserved
Copyright ©  2004 SYS-CON Media, Inc.
  E-mail: [email protected]

Java and Java-based marks are trademarks or registered trademarks of Sun Microsystems, Inc. in the United States and other countries. SYS-CON Publications, Inc. is independent of Sun Microsystems, Inc.