Speed up file searching in JFileChooser

Implement a type-ahead feature for faster file selection

You'd be hard-pressed to find an application these days that does not require a user to choose a file at some stage. To cater to that need, the Swing collection provides a JFileChooser component that makes it easy to visually navigate the filesystem and select a file or directory from a list.

Traditionally, users select a file in the file chooser's dialog by scrolling the chooser's list with a mouse and double-clicking the desired file. That method is fine for directories with a small number of files, but it becomes cumbersome and time-consuming when you're dealing with large directories with hundreds of files.

This article demonstrates how to implement an alternative way of choosing files by typing the first few characters of a filename. Once the selection bar is on the desired name, the user presses the Enter key to choose it. The type-ahead feature is implemented for files only, but you can easily extend it to include subdirectories as well.

Listening to user keystrokes

The JFileChooser is a compound component, consisting of a number of child components. Those child components are standard Swing components, such as JButton, JPanel, JComboBox, JList, and others. JFileChooser has only one JList component, which is wrapped in a scroll pane, and used to display subdirectories and files in the current directory (as shown in Figure 1). I've highlighted that component with a red border.

Figure 1. JList descendent component in JFileChooser

To implement the type-ahead feature in JFileChooser, the utility must know the user's keystrokes. You could do that by registering the utility as a key listener on the list. Unfortunately, the list is declared private, so the utility does not have direct access to it. You can, however, obtain a reference to the list: the getComponents() method of the Container class returns all components added to a specified container, including those declared private.

Now you can easily go through a list of all JFileChooser's descendant components to find the only JList component. A recursive method seems the most natural in this situation:

private Component findJList(Component comp) {
    if (comp.getClass() == JList.class) return comp;
    if (comp instanceof Container) {
        Component[] components = ((Container)comp).getComponents();
        for(int i = 0; i < components.length; i++) {
            Component child = findJList(components[i]);
            if (child != null) return child;
        }
    }
    return null;
}

Initially the findJList() method is passed a JFileChooser object, which itself is a container. The method retrieves all child components of that container by means of the getComponents() method, and recursively calls itself until a descendent component is found, which is an instance of JList. If the method is passed a component that is neither an instance of JList nor a container, it returns a null value to indicate that recursion process should continue. Once a JList component is found, findJList() exits from the for loop returning that component, which ends recursion calls.

Now the utility has a reference to the list component and can register as a key listener on it. The utility is interested in just one particular key event, namely when the key is typed. Therefore, the utility class extends the KeyAdapter class and implements the keyTyped() method only:

public class TypeAheadSelector extends KeyAdapter {
    private JFileChooser chooser;
    public TypeAheadSelector(JFileChooser) {
        this.chooser = chooser;
        Component comp = findJList(chooser);
        comp.addKeyListener(this);
        // more code will be added later
    }
    public void keyTyped(KeyEvent ke) {
        // user's keystrokes are processed here
    }
}

The keyTyped() method, which I discuss below, contains the core functionality of the type-ahead feature.

Retrieving files from the current directory

The information contained in this section is based on source code analysis of JFileChooser and its helper classes; in particular, BasicFileChooserUI, BasicDirectoryModel, and FileSystemView.

JFileChooser, like all other Swing components, is based on the Model-View-Controller architecture (MVC). The model passes its data to the view for rendering, the view decides which events are passed to the controller, and the controller updates the model based on the events received. Swing actually uses a simplified version of the MVC architecture called the model-delegate. That version combines the view and the controller into the so-called UI delegate.

JFileChooser's getUI() method returns a reference to the file chooser's UI delegate. That delegate implements a FileChooserUI interface indirectly by extending the BasicFileChooserUI class, which in turn implements FileChooserUI. BasicFileChooserUI has an important getModel() method. That method returns a reference to JFileChooser's model responsible for retrieving files from the current directory. The model is implemented in the BasicDirectoryModel class that not only retrieves files but also sorts and filters them.

BasicDirectoryModel uses the FileSystemView helper class, which you can obtain by calling getFileSystemView() on JFileChooser. That class represents a system-independent view of the file system. For the purpose of efficient rendering, the model should maintain a kind of file cache, so that filenames are quickly retrieved from a cache and rendered on the screen when the user scrolls the list in the file chooser's dialog. Although that cache is declared private to BasicDirectoryModel, methods are provided that give access to its contents.

First, BasicDirectoryModel retrieves all items from the current directory using FileSystemView's getFiles() method, sorts them alphabetically, and finally passes them through the current filter of JFileChooser in groups (because filtering takes around 30 times longer than sorting).

The model performs all of that activity in a separate thread and delivers subdirectories, first in groups of 10 and then all files in one big group. The model fires a contentsChanged() event to notify all interested parties whenever a new group of items arrives. BasicDirectoryModel has the getFile() method that, when first called, may return a zero-length vector (because subdirectories arrive first and files arrive after some delay), but when the last group of items arrives with files inside, getFile() returns all files in the current directory, already filtered and sorted. So the idea behind retrieving files from the model's cache is to register as a ListDataListener on the model and then wait inside the contentsChanged() method until the model's getFiles() method returns a nonzero vector:

public class TypeAheadSelector extends KeyAdapter {
    private JFileChooser chooser;
    private Vector files;
    public TypeAheadSelector(JFileChooser) {
        this.chooser = chooser;
        Component comp = findJList(chooser);
        comp.addKeyListener(this);
        setListDataListener();
        // more code will be added later
    }
    private void setListDataListener() {
        final BasicDirectoryModel model = 
            ((BasicFileChooserUI)chooser.getUI()).getModel();
        model.addListDataListener(new ListDataListener() {
            public void contentsChanged(ListDataEvent lde) {
                Vector buffer = model.getFiles();
                if (buffer.size() > 0) {
                    files = buffer;
                }
            }
            public void intervalAdded(ListDataEvent lde) {}
            public void intervalRemoved(ListDataEvent lde) {}
        });
    }
}

Processing user keystrokes

The utility remembers user keystrokes in an internal buffer. Whenever the user presses a key on the keyboard, the utility searches its local file list for filenames beginning with that character. If a match is found, the selection bar is moved to select the first matched filename. The buffer is cleared when the user presses any cursor key and changes the current directory or filter.

public void keyTyped(KeyEvent ke) {
    if (ke.getKeyChar() == KeyEvent.VK_ENTER) {
        if (chooser.getSelectedFile().isFile()) chooser.approveSelection();
    }
    partialName.append(ke.getKeyChar());
    String upperCasePartialName = partialName.toString().toUpperCase();
    for(int i = 0; i < files.size(); i++) {
        File item = (File)files.get(i);
        String name = item.getName().toUpperCase();
        if (name.startsWith(upperCasePartialName)) {
            resetPartialName = false;
            chooser.setSelectedFile(item);
            return;
        }
    }
}

That method's first three lines enable the user to approve the currently selected file by pressing the Enter key on the keyboard. If that key is pressed and the selected item in the list is a file, the utility invokes the approveSelection() method on JFileChooser to choose that file. If the key pressed is different than the Enter key, that key's character is appended to a partialName buffer. Next, the utility goes through its list of files, searching for the first file whose name begins with the characters in the buffer. Please note that matching is not case-sensitive. If the search is successful, the utility moves the selection bar to the file found, using the setSelectedFile() method of JFileChooser. I'll explain the meaning of a resetPartialName flag in a moment.

The utility needs to clear the buffer whenever the current directory or filter changes, or when the user changes the file selection by pressing any of the cursor keys or clicking the mouse. The utility should not reset the buffer when the change to the selected file was caused by the setSelectedFile() method in keyTyped(). That is where the resetPartialName flag plays its role. The flag is reset to false only just prior to calling setSelectedFile() and is set to true every time the selected file change happens. That guarantees that the buffer is not cleared when the selected file is changed programmatically from within the utility. To be notified of the selected file changes, the utility registers as a property change listener on JFileChooser:

public class TypeAheadSelector extends KeyAdapter 
                               implements PropertyChangeListener {
    private JFileChooser chooser;
    private StringBuffer partialName = new StringBuffer();
    private Vector files;
    private boolean resetPartialName = true;
    public TypeAheadSelector(JFileChooser chooser) {
        this.chooser = chooser;
        Component comp = findJList(chooser);
        comp.addKeyListener(this);
        setListDataListener();
        chooser.addPropertyChangeListener(this);
    }
    public void propertyChange(PropertyChangeEvent e) {
        String prop = e.getPropertyName();
        if (prop.equals(JFileChooser.SELECTED_FILE_CHANGED_PROPERTY)) {
            if (resetPartialName) partialName.setLength(0);
            resetPartialName = true;
        }
    }
}

Earlier, I said that the utility clears its buffer when the current directory or filter changes. Let me explain why listening to the selected file-change events alone is sufficient to achieve that. When the current directory or filter changes as a result of the user's explicit action (clicking the parent directory button at the top of the dialog or selecting a new filter in the choosable filters' combo box), the selection bar in the file list is cleared and the list loses focus. Later on, when the user clicks inside the list again, an item is selected, which generates a selected file change event, which in turn clears the buffer.

Figure 2 is a snapshot of a simple program that displays a frame with a text area and a button.

Figure 2. Type-ahead selector demo

When the user clicks the Display Dialog button, the program displays a file-open dialog, which is an instance of JFileChooser with the type-ahead feature. The result of a user's selection is displayed in the text area (a complete source code of the program is included in Resources). The most interesting part of the code is listed below:

    final JFileChooser chooser = new JFileChooser();
    new TypeAheadSelector(chooser);
    JButton button = new JButton("Display Dialog");
    button.addActionListener(new ActionListener() {
        public void actionPerformed(ActionEvent ae) {
            int retVal = chooser.showOpenDialog(frame);
            if (retVal == JFileChooser.APPROVE_OPTION) {
                area.append("Selected file: " + 
                    chooser.getSelectedFile().getName() + '\n');
            }
        }
    });

Note that in order to add the type-ahead feature to JFileChooser, you need to add just one line to your code (as shown in boldface).

1 2 Page 1