001    /*
002     * Copyright 2002-2004 the original author or authors.
003     * 
004     * Licensed under the Apache License, Version 2.0 (the "License"); you may not
005     * use this file except in compliance with the License. You may obtain a copy of
006     * the License at
007     * 
008     * http://www.apache.org/licenses/LICENSE-2.0
009     * 
010     * Unless required by applicable law or agreed to in writing, software
011     * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
012     * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
013     * License for the specific language governing permissions and limitations under
014     * the License.
015     */
016    package org.springframework.richclient.list;
017    
018    import java.awt.event.FocusEvent;
019    import java.awt.event.FocusListener;
020    import java.awt.event.KeyAdapter;
021    import java.awt.event.KeyEvent;
022    import java.util.HashMap;
023    import java.util.Map;
024    
025    import javax.swing.ComboBoxModel;
026    import javax.swing.JComboBox;
027    import javax.swing.UIManager;
028    import javax.swing.event.ListDataEvent;
029    import javax.swing.event.ListDataListener;
030    import javax.swing.text.AttributeSet;
031    import javax.swing.text.BadLocationException;
032    import javax.swing.text.JTextComponent;
033    import javax.swing.text.PlainDocument;
034    
035    import org.springframework.util.Assert;
036    
037    /**
038     * Provides AutoCompletion to a combobox. Works with the editor of the JComboBox
039     * to make the conversion between strings and the objects of the JComboBox
040     * model. <br>
041     * Based on code contributed to the public domain by Thomas Bierhance
042     * (http://www.orbital-computer.de/JComboBox/)
043     * 
044     * @author Peter De Bruycker
045     * @author Thomas Bierhance
046     */
047    public class ComboBoxAutoCompletion extends PlainDocument {
048    
049        private final ChangeHandler changeHandler = new ChangeHandler();
050    
051        private final JComboBox comboBox;
052    
053        private final JTextComponent editor;
054    
055        boolean hitBackspace;
056    
057        boolean hitBackspaceOnSelection;
058    
059        private Map item2string = new HashMap();
060    
061        private ComboBoxModel model;
062    
063        private boolean selectingValue;
064    
065        /**
066         * Adds autocompletion support to the given <code>JComboBox</code>.
067         * 
068         * @param comboBox
069         *            the combobox
070         */
071        public ComboBoxAutoCompletion(final JComboBox comboBox) {
072            Assert.notNull(comboBox, "The ComboBox cannot be null.");
073            Assert.isTrue(!comboBox.isEditable(), "The ComboBox must not be editable.");
074            Assert.isTrue(comboBox.getEditor().getEditorComponent() instanceof JTextComponent,
075                    "Only ComboBoxes with JTextComponent as editor are supported.");
076    
077            this.comboBox = comboBox;
078            comboBox.setEditable(true);
079    
080            model = comboBox.getModel();
081            model.addListDataListener(changeHandler);
082    
083            editor = (JTextComponent)comboBox.getEditor().getEditorComponent();
084            editor.setDocument(this);
085            editor.addFocusListener(changeHandler);
086            editor.addKeyListener(changeHandler);
087    
088            fillItem2StringMap();
089    
090            // Handle initially selected object
091            Object selected = comboBox.getSelectedItem();
092            comboBox.getEditor().setItem(selected);
093        }
094    
095        private void fillItem2StringMap() {
096            editor.setDocument(new PlainDocument());
097    
098            item2string.clear();
099    
100            JTextComponent editor = (JTextComponent)comboBox.getEditor().getEditorComponent();
101    
102            // get current item of editor
103            Object currentItem = comboBox.getEditor().getItem();
104            for (int i = 0; i < comboBox.getItemCount(); i++) {
105                Object item = comboBox.getItemAt(i);
106                comboBox.getEditor().setItem(item);
107                item2string.put(item, editor.getText());
108            }
109            // reset item in editor
110            comboBox.getEditor().setItem(currentItem);
111    
112            editor.setDocument(this);
113        }
114    
115        private String getStringFor(Object item) {
116            return (String)item2string.get(item);
117        }
118    
119        private void highlightCompletedText(int start) {
120            editor.setCaretPosition(getLength());
121            editor.moveCaretPosition(start);
122        }
123    
124        /**
125         * @see javax.swing.text.Document#insertString(int, java.lang.String,
126         *      javax.swing.text.AttributeSet)
127         */
128        public void insertString(int offs, String str, AttributeSet a) throws BadLocationException {
129            // ignore empty insert
130            if (str == null || str.length() == 0)
131                return;
132            if(selectingValue)
133                return;
134            // check offset position
135            if (offs < 0 || offs > getLength())
136                throw new BadLocationException("Invalid offset - must be >= 0 and <= " + getLength(), offs);
137    
138            // construct the resulting string
139            String currentText = getText(0, getLength());
140            String beforeOffset = currentText.substring(0, offs);
141            String afterOffset = currentText.substring(offs, currentText.length());
142            String futureText = beforeOffset + str + afterOffset;
143    
144            // lookup and select a matching item
145            Object item = lookupItem(futureText);
146            if (item != null) {
147                selectingValue = true; 
148                try {
149                    comboBox.setSelectedItem(item);
150                } finally {
151                    selectingValue = false;
152                }            
153            }
154            else {
155                // keep old item selected if there is no match
156                item = comboBox.getSelectedItem();
157                // imitate no insert (later on offs will be incremented by
158                // str.length(): selection won't move forward)
159                offs = offs - str.length();
160                // provide feedback to the user that his input has been received but
161                // can not be accepted
162                // comboBox.getToolkit().beep();
163                // when available use:
164                UIManager.getLookAndFeel().provideErrorFeedback(comboBox);
165            }
166    
167            // display the completed string
168            String itemString = item == null ? "" : getStringFor(item);
169            setText(itemString);
170    
171            // if the user selects an item via mouse the the whole string will be
172            // inserted.
173            // highlight the entire text if this happens.
174            if (itemString != null) {
175                if (itemString.equals(str) && offs == 0) {
176                    highlightCompletedText(0);
177                }
178                else {
179                    highlightCompletedText(offs + str.length());
180                    // show popup when the user types
181                    if (comboBox.isShowing()) {
182                        comboBox.setPopupVisible(true);
183                    }
184                }
185            }
186        }
187    
188        private Object lookupItem(String pattern) {
189            Object selectedItem = model.getSelectedItem();
190            // only search for a different item if the currently selected does not
191            // match
192            if (selectedItem != null && startsWithIgnoreCase(getStringFor(selectedItem), pattern)) {
193                return selectedItem;
194            }
195    
196            // iterate over all items
197            for (int i = 0, n = model.getSize(); i < n; i++) {
198                Object currentItem = model.getElementAt(i);
199                // current item starts with the pattern?
200                if (startsWithIgnoreCase(getStringFor(currentItem), pattern)) {
201                    return currentItem;
202                }
203            }
204    
205            // no item starts with the pattern => return null
206            return null;
207        }
208    
209        /**
210         * @see javax.swing.text.Document#remove(int, int)
211         */
212        public void remove(int offs, int length) throws BadLocationException {
213            // ignore no deletion
214            if (length == 0)
215                return;
216            // check positions
217            if (offs < 0 || offs > getLength() || length < 0 || (offs + length) > getLength())
218                throw new BadLocationException("Invalid parameters.", offs);
219    
220            if (hitBackspace) {
221                // user hit backspace => move the selection backwards
222                // old item keeps being selected
223                if (offs > 0) {
224                    if (hitBackspaceOnSelection)
225                        offs--;
226                }
227                else {
228                    // User hit backspace with the cursor positioned on the start =>
229                    // beep
230                    comboBox.getToolkit().beep();
231                    // when available use:
232                    // UIManager.getLookAndFeel().provideErrorFeedback(comboBox);
233                }
234                highlightCompletedText(offs);
235                // show popup when the user types
236                if (comboBox.isShowing())
237                    comboBox.setPopupVisible(true);
238            }
239            else {
240                super.remove(offs, length);
241            }
242        }
243    
244        private void setText(String text) throws BadLocationException {
245            // remove all text and insert the new text
246            super.remove(0, getLength());
247            super.insertString(0, text, null);
248        }
249    
250        // checks if str1 starts with str2 - ignores case
251        private boolean startsWithIgnoreCase(String str1, String str2) {
252            return str1 != null && str2 != null && str1.toUpperCase().startsWith(str2.toUpperCase());
253        }
254    
255        private final class ChangeHandler extends KeyAdapter implements FocusListener, ListDataListener {
256    
257            // Highlight whole text when user hits enter
258            // Register when user hits backspace
259            public void keyPressed(KeyEvent e) {
260                hitBackspace = false;
261                switch (e.getKeyCode()) {
262                case KeyEvent.VK_ENTER:
263                    highlightCompletedText(0);
264                    break;
265                // determine if the pressed key is backspace (needed by the remove
266                // method)
267                case KeyEvent.VK_BACK_SPACE:
268                    hitBackspace = true;
269                    hitBackspaceOnSelection = editor.getSelectionStart() != editor.getSelectionEnd();
270                    break;
271                // ignore delete key
272                case KeyEvent.VK_DELETE:
273                    e.consume();
274                    ComboBoxAutoCompletion.this.comboBox.getToolkit().beep();
275                    break;
276                }
277            }
278    
279            // Bug 5100422 on Java 1.5: Editable JComboBox won't hide popup when
280            // tabbing out
281            private boolean hidePopupOnFocusLoss = System.getProperty("java.version").startsWith("1.5");
282    
283            public void focusGained(FocusEvent e) {
284                // Highlight whole text when gaining focus
285                highlightCompletedText(0);
286            }
287    
288            public void focusLost(FocusEvent e) {
289                // Workaround for Bug 5100422 - Hide Popup on focus loss
290                if (hidePopupOnFocusLoss)
291                    ComboBoxAutoCompletion.this.comboBox.setPopupVisible(false);
292            }
293    
294            public void contentsChanged(ListDataEvent e) {
295                if(!selectingValue)
296                    fillItem2StringMap();
297            }
298    
299            public void intervalAdded(ListDataEvent e) {
300                fillItem2StringMap();
301            }
302    
303            public void intervalRemoved(ListDataEvent e) {
304                fillItem2StringMap();
305            }
306        }
307    }