Help needed refactoring my AutofillTextField

Hello everyone,

Implementing an autofill feature for JTextField raised several design issues, and I would like some help.

First, source code, demo and a diagram can be found on my JRoller entry: http://www.jroller.com/page/handcream?entry=refactoring_challenge_autocomplete_jtextfield

Most difficulties are due to the mix of input and output. I imply what was auto-filled by selecting that part of the text. This affects the input on the other end.

An auto-filled selection doesn't behave like a normal one. Backspace will delete both the selection and an extra character. After that it immediately searches for a match, and auto-fills if necessary.

Adding characters in the middle and getting a match, advances the caret as normal, but also auto-selects the text from that point. So the output "talks" to the input to get the normal caret position.

To intercept text changes in JTextField I override PlainDocument, but other objects need access to the original, super-class methods. For this they use an inner class with methods wrapping the super-class methods.

I wonder if this overriding forces some dirty code, or maybe I can always abstract it away. Maybe I must intercept text changes some other way to make everything cleaner, like removing all keyListeners and add my own. But I doubt that.

My guess is that i'm just unable yet to see the right abstractions, and maybe I didn't go far enough with seperation. Anyway, I need help :). I tried not to ask, but this is going on for too long.

Thanks,

Yonatan.

Some code:

publicinterface Output{

void showMatch(String userInput, String fixedInput, String fill);

void showMismatch(String userInput);

}

publicinterface Processor{

void match(String userInput, Output output);

}

publicinterface RealDocument{

void insertString(int offset, String addition);

void remove(int offset,int length);

void clear();

String getText();

}

publicclass AutoFillDocumentextends javax.swing.text.PlainDocument{

@Override

publicvoid insertString(int offset, java.lang.String addition, javax.swing.text.AttributeSet attributes)throws javax.swing.text.BadLocationException{

replacing =false;

String proposedValue =new StringBuilder(real.getText()).insert(offset, addition).toString();

TextFieldOutput output =new TextFieldOutput(field, real,new DefaultInsert(offset, addition));

search.match(proposedValue, output);

autoFilledState = output.autoFilled();

}

@Override

publicvoid remove(int offset,int length)throws javax.swing.text.BadLocationException{

if (replacing){

real.remove(offset, length);

replacing =false;

}else{

removeDirect(offset, length);

}

}

privatevoid removeDirect(int offset,int length){

if (backspacing && autoFilledState){

offset--;

length++;

}

removeDelegate(offset, length);

}

privatevoid removeDelegate(int offset,int length){

String proposedValue =new StringBuilder(real.getText()).delete(offset, offset + length).toString();

TextFieldOutput output =new TextFieldOutput(field, real,new DefaultRemove(offset, length));

search.match(proposedValue, output);

autoFilledState = output.autoFilled();

}

@Override

publicvoid replace(int arg0,int arg1, java.lang.String arg2, javax.swing.text.AttributeSet arg3)throws javax.swing.text.BadLocationException{

replacing =true;

super.replace(arg0, arg1, arg2, arg3);

}

public AutoFillDocument(yon.ui.autofill.Processor search, javax.swing.JTextField field){

this.search = search;

this.field = field;

field.addKeyListener(new java.awt.event.KeyAdapter(){

publicvoid keyPressed(java.awt.event.KeyEvent e){

backspacing = java.awt.event.KeyEvent.VK_BACK_SPACE == e.getKeyCode();

}

});

}

yon.ui.autofill.Processor search;

javax.swing.JTextField field;

boolean backspacing =false;

boolean autoFilledState =false;

boolean replacing =false;

publicfinal RealDocument real =new RealDocument();

privateclass RealDocumentimplements yon.ui.autofill.RealDocument{

publicvoid insertString(int offset, String addition){

try{

AutoFillDocument.super.insertString(offset, addition,null);

}catch (Exception ex){

thrownew RuntimeException(ex);

}

}

publicvoid remove(int offset,int length){

try{

AutoFillDocument.super.remove(offset, length);

}catch (Exception ex){

thrownew RuntimeException(ex);

}

}

publicvoid clear(){

try{

AutoFillDocument.super.remove(0, AutoFillDocument.super.getLength());

}catch (Exception ex){

thrownew RuntimeException(ex);

}

}

public String getText(){

try{

return AutoFillDocument.super.getText(0, AutoFillDocument.super.getLength());

}catch (Exception ex){

thrownew RuntimeException(ex);

}

}

};

privateclass DefaultInsertimplements yon.ui.autofill.TextFieldOutput.DefaultAction{

publicvoid execute(){

real.insertString(offset, addition);

}

public DefaultInsert(int offset, String addition){

this.offset = offset;

this.addition = addition;

}

privateint offset;

private String addition;

}

privateclass DefaultRemoveimplements yon.ui.autofill.TextFieldOutput.DefaultAction{

publicvoid execute(){

real.remove(offset, length);

}

public DefaultRemove(int offset,int length){

this.offset = offset;

this.length = length;

}

privateint offset;

privateint length;

}

}

publicclass DefaultProcessorimplements Processor{

publicvoid match(String userInput, Output output){

String match = startsWith(userInput);

if (match ==null){

output.showMismatch(userInput);

}else{

output.showMatch(userInput, match.substring(0, userInput.length()), match.substring(userInput.length()));

}

}

public String startsWith(String prefix){

yon.utils.IsStringPrefix isPrefix =new yon.utils.IsStringPrefix(prefix);

for (String option: options){

if (isPrefix.of(option)){

return option;

}

}

returnnull;

}

public DefaultProcessor(String[] options){

this.options = options;

}

String[] options;

}

publicclass TextFieldOutputimplements Output{

publicvoid showMatch(String userInput, String fixedInput, String fill){

if (!userInput.isEmpty()){

if (fill.isEmpty()){// exact match

defaultAction.execute();

int caretPosition = field.getCaret().getDot();

field.getCaret().setDot(fixedInput.length());

field.getCaret().moveDot(caretPosition);

}else{

document.clear();

document.insertString(0, fixedInput + fill);

field.getCaret().setDot(fixedInput.length() + fill.length());

field.getCaret().moveDot(fixedInput.length());

}

autoFilled = field.getCaret().getDot() != field.getCaret().getMark();

}else{

document.clear();

autoFilled =false;

}

}

publicvoid showMismatch(String userInput){

defaultAction.execute();

autoFilled =false;

}

publicboolean autoFilled(){

return autoFilled;

}

public TextFieldOutput(javax.swing.JTextField field, yon.ui.autofill.RealDocument document, DefaultAction defaultAction){

this.field = field;

this.document = document;

this.defaultAction = defaultAction;

}

private javax.swing.JTextField field;

private yon.ui.autofill.RealDocument document;

private DefaultAction defaultAction;

privateboolean autoFilled;

publicinterface DefaultAction{

void execute();

}

}

publicclass AutoFillFeature{

publicvoid installIn(javax.swing.JTextField field){

Processor processor =new DefaultProcessor(options);

AutoFillDocument document =new AutoFillDocument(processor, field);

field.setDocument(document);

}

public AutoFillFeature(String[] options){

this.options = options;

}

String[] options;

}

[16970 byte] By [gewittera] at [2007-11-26 13:26:53]
# 1

I made the code very simple in the interest of discussion in this forum.

1) Now everything is in one class.

2) I extend DocumentFilter instead of Document, which already implements the acrobatics I tried earlier.

3) I Commented most parts of the code.

How would you refactor it? It can be as simple as splitting into more methods, but you can also introduce more objects, and maybe even use a GoF pattern.

Here is the code. Good luck :)

package yon.ui.autofill;

public class AutoFillFilter extends javax.swing.text.DocumentFilter {

// Called when user tries to insert a string into a text-box.

// bypass - can change the text without causing a recursion.

// offset - where to insert

// addition - what to insert

// attributes - mostly ignored

@Override

public void insertString(FilterBypass bypass, int offset, String addition, javax.swing.text.AttributeSet attributes) throws javax.swing.text.BadLocationException {

bypass.insertString(offset, addition, attributes);

handleInput(bypass, currentValue(bypass));

}

// Called when user tries to remove a string in a text-box.

// bypass - can change the text without causing a recursion.

// offset - where to start removal

// length - how much to remove

@Override

public void remove(FilterBypass bypass, int offset, int length) throws javax.swing.text.BadLocationException {

if (backspacing && autoFilled) {

offset--;

length++;

}

bypass.remove(offset, length);

handleInput(bypass, currentValue(bypass));

}

// Called when user tries to replace a string in a text-box.

// bypass - can change the text without causing a recursion.

// offset - replaced section start

// length - replaced section length

// substitution - ...

// attributes - mostly ignored

@Override

public void replace(FilterBypass bypass, int offset, int length, String substitution, javax.swing.text.AttributeSet attributes) throws javax.swing.text.BadLocationException {

bypass.remove(offset, length);

insertString(bypass, offset, substitution, null);

}

// Searching for a match and auto-fill.

// bypass - can change the text without causing a recursion.

// input - user input

private void handleInput(FilterBypass bypass, String input) {

String match = options.firstStartsWith(input);

handleMatchResult(match, input, bypass);

}

// Check match and display in text-box accordingly.

// match - result from searching options

// input - raw input from user

// bypass - can change the text without causing a recursion.

private void handleMatchResult(String match, String input, FilterBypass bypass) {

// If no match, or match was default (empty string is prefix of anything)

//leave text-box as it is.

// If matched, imply by selecting the autu-filled section.

if (match == null || input.isEmpty()) {

autoFilled = false;

} else {

showMatch(bypass, match);

}

}

// Displays text with auto selected section.

// It knows where to start selecting according to the current caret state.

private void showMatch(FilterBypass bypass, String match) {

int caretPosition = field.getCaret().getDot();

clear(bypass);

bypassInsertString(bypass, 0, match);

mark(match.length(), caretPosition);

autoFilled = field.getCaret().getDot() != field.getCaret().getMark();

}

// Convenience method to get the whole text-box text.

private String currentValue(FilterBypass bypass) {

try {

return bypass.getDocument().getText(0, bypass.getDocument().getLength());

} catch (Exception ex) {

throw new RuntimeException(ex);

}

}

// Convenience method to insert a string to text-box.

private void bypassInsertString(FilterBypass bypass, int offset, String addition) {

try {

bypass.insertString(offset, addition, null);

} catch (Exception ex) {

throw new RuntimeException(ex);

}

}

// Convenience method to clear all text from text-box

private void clear(FilterBypass bypass) {

try {

bypass.remove(0, bypass.getDocument().getLength());

} catch (Exception ex) {

throw new RuntimeException(ex);

}

}

// Convenience method to mark (select) a text-box section

private void mark(int from, int to) {

field.getCaret().setDot(from);

field.getCaret().moveDot(to);

}

// Constructor

// Registers a listener to BACKSPACE typing, so we don't just delete a selection,

//but delete one extra character. otherwise users can't backspace more than once,

//if their input was auto-filled.

// Registers a listener to text-box caret, so we know if a section of text-box is

//selected automatically by us. (it helps together with backspace to delete one

//extra character.

public AutoFillFilter(javax.swing.JTextField field, Options options) {

this.options = options;

this.field = field;

// Similar to C# delegator.

this.field.addKeyListener(new java.awt.event.KeyAdapter() {

public void keyPressed(java.awt.event.KeyEvent e) {

backspacing = java.awt.event.KeyEvent.VK_BACK_SPACE == e.getKeyCode();

}

});

// Similar to C# delegator.

field.getCaret().addChangeListener(new javax.swing.event.ChangeListener() {

public void stateChanged(javax.swing.event.ChangeEvent arg0) {

autoFilled = false; // Factory trusts Output to set it only after changing caret, so next caret change fill cancel autofill.

}

});

}

// Was the current text auto-filled and now in an auto-selection state?

private boolean autoFilled;

// Options to match against when trying to auto-fill.

private Options options;

// Did the user just pressed Backspace?

private boolean backspacing = false;

// The text-box.

javax.swing.JTextField field;

}

// One extra class if anyone wants to build and run it.

package yon.ui.autofill;

public class Options {

public String firstStartsWith(String prefix) {

yon.utils.IsPrefix isPrefix = new yon.utils.IsPrefix(prefix);

for (String option: options) {

if (isPrefix.of(option)) {

return option;

}

}

return null;

}

public Options(String... options) {

this.options = options;

}

String[] options;

}

// Oh and this one for prefix checking:

package yon.utils;

public class IsPrefix {

public boolean of(String str) {

return str.regionMatches(true, 0, prefix, 0, prefix.length());

}

public IsPrefix(String prefix) {

this.prefix = prefix;

}

String prefix;

}

// And a demo so you have a quick start.

package yon.ui.autofill;

public class MiniApp {

public static void main(String[] args) {

javax.swing.SwingUtilities.invokeLater(new Runnable() {

public void run() {

javax.swing.JTextField field = new javax.swing.JTextField();

Options options = new Options("English", "Eng12lish", "French", "German", "Heb", "Hebrew");

makeAutoFill(field, options);

javax.swing.JLabel label = new javax.swing.JLabel("English, Eng12lish, French, German, Heb, Hebrew");

javax.swing.JPanel panel = new javax.swing.JPanel();

panel.setLayout(new javax.swing.BoxLayout(panel, javax.swing.BoxLayout.PAGE_AXIS));

panel.add(field);

panel.add(javax.swing.Box.createVerticalStrut(10));

panel.add(label);

javax.swing.JFrame frame = new javax.swing.JFrame();

frame.add(panel);

frame.setDefaultCloseOperation(javax.swing.JFrame.EXIT_ON_CLOSE);

frame.pack();

frame.setVisible(true);

}

});

}

public static void makeAutoFill(javax.swing.JTextField field, Options options) {

javax.swing.text.PlainDocument document = new javax.swing.text.PlainDocument();

document.setDocumentFilter(new AutoFillFilter(field, options));

field.setDocument(document);

}

}

gewittera at 2007-7-7 20:27:07 > top of Java-index,Other Topics,Patterns & OO Design...
# 2

Throw out the IsPrefix class - you've rewritten String.startsWith().

I'd throw out the Options class, pass around String[]s, and move

the method into AutoFillFilter.

This is typically implemented as a tree of characters, because it

improves the running time from O(n) to O(n log n). If you're only

dealing with a few hundred strings, this probably isn't a big deal,

but if your dictionary is in the hundreds of thousands, it probably is.

I'm sure there are implementations of this out there. I know this

has been discussed before on the forums. You might want to run

a search and see what else is out there.

es5f2000a at 2007-7-7 20:27:07 > top of Java-index,Other Topics,Patterns & OO Design...
# 3

Or use an indexing scheme, a la Lucene.

This is one of those "everyone does it" things for AJAX. Looks like you want to accomplish the same thing with Swing.

I'd caution you against putting too much of that logic in the Swing classes. Leave it on the server. You don't want to have to have a huge dictionary on the client for this to work.

%

duffymoa at 2007-7-7 20:27:07 > top of Java-index,Other Topics,Patterns & OO Design...
# 4

Well, actually i'm more interested in the auto-fill behavior:

Checking for backspacing

Checking for auto-fill state

Using a small subset of JTextField and Document's methods,

sometimes together, treating them maybe as one object.

I'm not going to write a Prefix-Tree or use one for this application.

Theoretically, the options are all the human languages,

but no more than 20 will actually be defined by the client.

As for the IsPrefix, I just wanted a readable way to get a case-insensitive startsWith.

I could lower both strings and be done with it, but I wanted to experiment.

In addition, though not a valid argument, regionMatch was said to be faster.

The next step is to introduce multiple values into the same text field, each auto-filled independently.

I tried to communicate auto-fill behavior better in advance, refactoring back and forth with no satisfying result.

Meanwhile I can afford these experiments, but will soon give up,

implement the new functionality, and only then refactor, probably discovering everything I needed now.

My original call for help can still be answered.

I will certainly listen to the XP people on the next feature...

gewittera at 2007-7-7 20:27:07 > top of Java-index,Other Topics,Patterns & OO Design...
# 5
If you've only got 20 options, you should probably be using a JComboBox. It may even have the autocomplete functionalitybuilt in.
es5f2000a at 2007-7-7 20:27:07 > top of Java-index,Other Topics,Patterns & OO Design...
# 6

I am going for multi-selection. The simplest thing would be a JList. Next, my CheckBoxList. Then, just parsing JTextField's text on losing focus.

I do want something to pop up, but i'm not sure customizing combo-box will be

any easier than using a simple text-field.

One of the requirements states that this should be "just" a smart text input,

meaning they can copy-paste, delete whatever part of the text including a

delimiter, and not be prevented typing wrong values. It should however tell

the user which values are wrong and prevent committing the data. Also tell them

which values are right by auto-filling them.

It's not that hard to hack it in, and that's what i'm going to do now, unfortunately.

gewittera at 2007-7-7 20:27:07 > top of Java-index,Other Topics,Patterns & OO Design...
# 7

Well, then your requirements are t3h suck. Requirements shouldn't

specify design. Also, they're requiring you to use the wrong controls.

The right way to prevent the user from typing in the wrong thing here

is not to let them type. Making a textfield that does autocomplete

for multiple words when there are only twenty choices is a bad

waste of development effort for a bad UI.

Not that they'll pay any attention, but feel free to tell them I said so.

Doesn't sound like any of this is news to you. :(

es5f2000a at 2007-7-7 20:27:07 > top of Java-index,Other Topics,Patterns & OO Design...