001/*
002 * $Id: AutoCompleteDecorator.java 4051 2011-07-19 20:17:05Z kschaefe $
003 *
004 * Copyright 2004 Sun Microsystems, Inc., 4150 Network Circle,
005 * Santa Clara, California 95054, U.S.A. All rights reserved.
006 *
007 * This library is free software; you can redistribute it and/or
008 * modify it under the terms of the GNU Lesser General Public
009 * License as published by the Free Software Foundation; either
010 * version 2.1 of the License, or (at your option) any later version.
011 * 
012 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
015 * Lesser General Public License for more details.
016 * 
017 * You should have received a copy of the GNU Lesser General Public
018 * License along with this library; if not, write to the Free Software
019 * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
020 */
021package org.jdesktop.swingx.autocomplete;
022
023import static java.util.Arrays.asList;
024import static java.util.Collections.unmodifiableList;
025
026import java.awt.event.ActionEvent;
027import java.awt.event.ActionListener;
028import java.awt.event.FocusListener;
029import java.awt.event.KeyListener;
030import java.beans.PropertyChangeListener;
031import java.util.List;
032
033import javax.swing.Action;
034import javax.swing.ActionMap;
035import javax.swing.InputMap;
036import javax.swing.JComboBox;
037import javax.swing.JList;
038import javax.swing.KeyStroke;
039import javax.swing.UIManager;
040import javax.swing.event.ListSelectionListener;
041import javax.swing.plaf.UIResource;
042import javax.swing.text.DefaultEditorKit;
043import javax.swing.text.Document;
044import javax.swing.text.JTextComponent;
045import javax.swing.text.StyledDocument;
046import javax.swing.text.TextAction;
047
048import org.jdesktop.swingx.autocomplete.workarounds.MacOSXPopupLocationFix;
049
050/**
051 * This class contains only static utility methods that can be used to set up
052 * automatic completion for some Swing components.
053 * <p>Usage examples:</p>
054 * <p><pre><code>
055 * JComboBox comboBox = [...];
056 * AutoCompleteDecorator.<b>decorate</b>(comboBox);
057 * 
058 * List items = [...];
059 * JTextField textField = [...];
060 * AutoCompleteDecorator.<b>decorate</b>(textField, items);
061 * 
062 * JList list = [...];
063 * JTextField textField = [...];
064 * AutoCompleteDecorator.<b>decorate</b>(list, textField);
065 * </code></pre></p>
066 *
067 * @author Thomas Bierhance
068 * @author Karl Schaefer
069 */
070@SuppressWarnings({"nls", "serial"})
071public class AutoCompleteDecorator {
072    //these keys were pulled from BasicComboBoxUI from Sun JDK 1.6.0_20
073    private static final List<String> COMBO_BOX_ACTIONS = unmodifiableList(asList("selectNext",
074            "selectNext2", "selectPrevious", "selectPrevious2", "pageDownPassThrough",
075            "pageUpPassThrough", "homePassThrough", "endPassThrough"));
076    /**
077     * A TextAction that provides an error feedback for the text component that invoked
078     * the action. The error feedback is most likely a "beep".
079     */
080    private static final Object errorFeedbackAction = new TextAction("provide-error-feedback") {
081        @Override
082        public void actionPerformed(ActionEvent e) {
083            UIManager.getLookAndFeel().provideErrorFeedback(getTextComponent(e));
084        }
085    };
086    
087    private AutoCompleteDecorator() {
088        //prevents instantiation
089    }
090    
091    private static void installMap(InputMap componentMap, boolean strict) {
092        InputMap map = new AutoComplete.InputMap();
093        
094        if (strict) {
095            map.put(KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_BACK_SPACE, 0), DefaultEditorKit.selectionBackwardAction);
096            // ignore VK_DELETE and CTRL+VK_X and beep instead when strict matching
097            map.put(KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_DELETE, 0), errorFeedbackAction);
098            map.put(KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_X, java.awt.event.InputEvent.CTRL_DOWN_MASK), errorFeedbackAction);
099        } else {
100            // VK_BACKSPACE will move the selection to the left if the selected item is in the list
101            // it will delete the previous character otherwise
102            map.put(KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_BACK_SPACE, 0), "nonstrict-backspace");
103            // leave VK_DELETE and CTRL+VK_X as is
104        }
105        
106        map.setParent(componentMap.getParent());
107        componentMap.setParent(map);
108    }
109    
110    static AutoCompleteDocument createAutoCompleteDocument(
111            AbstractAutoCompleteAdaptor adaptor, boolean strictMatching,
112            ObjectToStringConverter stringConverter, Document delegate) {
113        if (delegate instanceof StyledDocument) {
114            return new AutoCompleteStyledDocument(adaptor, strictMatching,
115                    stringConverter, (StyledDocument) delegate);
116        }
117        
118        return new AutoCompleteDocument(adaptor, strictMatching,
119                stringConverter, delegate);
120    }
121    
122    /**
123     * Enables automatic completion for the given JComboBox. The automatic
124     * completion will be strict (only items from the combo box can be selected)
125     * if the combo box is not editable.
126     * @param comboBox a combo box
127     * @see #decorate(JComboBox, ObjectToStringConverter)
128     */
129    public static void decorate(JComboBox comboBox) {
130        decorate(comboBox, null);
131    }
132    
133    /**
134     * Enables automatic completion for the given JComboBox. The automatic
135     * completion will be strict (only items from the combo box can be selected)
136     * if the combo box is not editable.
137     * <p>
138     * <b>Note:</b> the {@code AutoCompleteDecorator} will alter the state of
139     * the {@code JComboBox} to be editable. This can cause side effects with
140     * layouts and sizing. {@code JComboBox} caches the size, which differs
141     * depending on the component's editability. Therefore, if the component's
142     * size is accessed prior to being decorated and then the cached size is
143     * forced to be recalculated, the size of the component will change.
144     * <p>
145     * Because the size of the component can be altered (recalculated), the
146     * decorator does not attempt to set any sizes on the supplied
147     * {@code JComboBox}. Users that need to ensure sizes of supplied combos
148     * should take measures to set the size of the combo.
149     * 
150     * @param comboBox
151     *                a combo box
152     * @param stringConverter
153     *                the converter used to transform items to strings
154     */
155    public static void decorate(JComboBox comboBox, ObjectToStringConverter stringConverter) {
156        undecorate(comboBox);
157        
158        boolean strictMatching = !comboBox.isEditable();
159        // has to be editable
160        comboBox.setEditable(true);
161        // fix the popup location
162        MacOSXPopupLocationFix.install(comboBox);
163
164        // configure the text component=editor component
165        JTextComponent editorComponent = (JTextComponent) comboBox.getEditor().getEditorComponent();
166        final AbstractAutoCompleteAdaptor adaptor = new ComboBoxAdaptor(comboBox);
167        final AutoCompleteDocument document = createAutoCompleteDocument(adaptor, strictMatching,
168                stringConverter, editorComponent.getDocument());
169        decorate(editorComponent, document, adaptor);
170        
171        editorComponent.addKeyListener(new AutoComplete.KeyAdapter(comboBox));
172        
173        //set before adding the listener for the editor
174        comboBox.setEditor(new AutoCompleteComboBoxEditor(comboBox.getEditor(), document.stringConverter));
175        
176        // Changing the l&f can change the combobox' editor which in turn
177        // would not be autocompletion-enabled. The new editor needs to be set-up.
178        AutoComplete.PropertyChangeListener pcl = new AutoComplete.PropertyChangeListener(comboBox);
179        comboBox.addPropertyChangeListener("editor", pcl);
180        comboBox.addPropertyChangeListener("enabled", pcl);
181        
182        if (!strictMatching) {
183            ActionMap map = comboBox.getActionMap();
184            
185            for (String key : COMBO_BOX_ACTIONS) {
186                Action a = map.get(key);
187                map.put(key, new AutoComplete.SelectionAction(a));
188            }
189        }
190    }
191
192    static void undecorate(JComboBox comboBox) {
193        JTextComponent editorComponent = (JTextComponent) comboBox.getEditor().getEditorComponent();
194        
195        if (editorComponent.getDocument() instanceof AutoCompleteDocument) {
196            AutoCompleteDocument doc = (AutoCompleteDocument) editorComponent.getDocument();
197            
198            if (doc.strictMatching) {
199                ActionMap map = comboBox.getActionMap();
200                
201                for (String key : COMBO_BOX_ACTIONS) {
202                    map.put(key, null);
203                }
204            }
205            
206            //remove old property change listener
207            for (PropertyChangeListener l : comboBox.getPropertyChangeListeners("editor")) {
208                if (l instanceof AutoComplete.PropertyChangeListener) {
209                    comboBox.removePropertyChangeListener("editor", l);
210                }
211            }
212            
213            for (PropertyChangeListener l : comboBox.getPropertyChangeListeners("enabled")) {
214                if (l instanceof AutoComplete.PropertyChangeListener) {
215                    comboBox.removePropertyChangeListener("enabled", l);
216                }
217            }
218            
219            AutoCompleteComboBoxEditor editor = (AutoCompleteComboBoxEditor) comboBox.getEditor();
220            comboBox.setEditor(editor.wrapped);
221            
222            //remove old key listener
223            for (KeyListener l : editorComponent.getKeyListeners()) {
224                if (l instanceof AutoComplete.KeyAdapter) {
225                    editorComponent.removeKeyListener(l);
226                    break;
227                }
228            }
229            
230            undecorate(editorComponent);
231            
232            for (ActionListener l : comboBox.getActionListeners()) {
233                if (l instanceof ComboBoxAdaptor) {
234                    comboBox.removeActionListener(l);
235                    break;
236                }
237            }
238            
239            //TODO remove aqua fix
240            
241            //TODO reset editibility
242        }
243    }
244    
245    /**
246     * Enables automatic completion for the given JTextComponent based on the
247     * items contained in the given JList. The two components will be
248     * synchronized. The automatic completion will always be strict.
249     * @param list a <tt>JList</tt> containing the items for automatic completion
250     * @param textComponent the text component that will be enabled for automatic
251     * completion
252     */
253    public static void decorate(JList list, JTextComponent textComponent) {
254        decorate(list, textComponent, null);
255    }
256    
257    /**
258     * Enables automatic completion for the given JTextComponent based on the
259     * items contained in the given JList. The two components will be
260     * synchronized. The automatic completion will always be strict.
261     * @param list a <tt>JList</tt> containing the items for automatic completion
262     * @param textComponent the text component that will be used for automatic
263     * completion
264     * @param stringConverter the converter used to transform items to strings
265     */
266    public static void decorate(JList list, JTextComponent textComponent, ObjectToStringConverter stringConverter) {
267        undecorate(list);
268        
269        AbstractAutoCompleteAdaptor adaptor = new ListAdaptor(list, textComponent, stringConverter);
270        AutoCompleteDocument document = createAutoCompleteDocument(adaptor, true, stringConverter, textComponent.getDocument());
271        decorate(textComponent, document, adaptor);
272    }
273
274    static void undecorate(JList list) {
275        for (ListSelectionListener l : list.getListSelectionListeners()) {
276            if (l instanceof ListAdaptor) {
277                list.removeListSelectionListener(l);
278                break;
279            }
280        }
281    }
282    
283    /**
284     * Enables automatic completion for the given JTextComponent based on the
285     * items contained in the given <tt>List</tt>.
286     * @param textComponent the text component that will be used for automatic
287     * completion.
288     * @param items contains the items that are used for autocompletion
289     * @param strictMatching <tt>true</tt>, if only given items should be allowed to be entered
290     */
291    public static void decorate(JTextComponent textComponent, List<?> items, boolean strictMatching) {
292        decorate(textComponent, items, strictMatching, null);
293    }
294    
295    /**
296     * Enables automatic completion for the given JTextComponent based on the
297     * items contained in the given <tt>List</tt>.
298     * @param items contains the items that are used for autocompletion
299     * @param textComponent the text component that will be used for automatic
300     * completion.
301     * @param strictMatching <tt>true</tt>, if only given items should be allowed to be entered
302     * @param stringConverter the converter used to transform items to strings
303     */
304    public static void decorate(JTextComponent textComponent, List<?> items, boolean strictMatching, ObjectToStringConverter stringConverter) {
305        AbstractAutoCompleteAdaptor adaptor = new TextComponentAdaptor(textComponent, items);
306        AutoCompleteDocument document = createAutoCompleteDocument(adaptor, strictMatching, stringConverter, textComponent.getDocument());
307        decorate(textComponent, document, adaptor);
308    }
309    
310    /**
311     * Decorates a given text component for automatic completion using the
312     * given AutoCompleteDocument and AbstractAutoCompleteAdaptor.
313     * 
314     * @param textComponent a text component that should be decorated
315     * @param document the AutoCompleteDocument to be installed on the text component
316     * @param adaptor the AbstractAutoCompleteAdaptor to be used
317     */
318    public static void decorate(JTextComponent textComponent, AutoCompleteDocument document, AbstractAutoCompleteAdaptor adaptor) {
319        undecorate(textComponent);
320        
321        // install the document on the text component
322        textComponent.setDocument(document);
323        
324        // mark entire text when the text component gains focus
325        // otherwise the last mark would have been retained which is quiet confusing
326        textComponent.addFocusListener(new AutoComplete.FocusAdapter(adaptor));
327        
328        // Tweak some key bindings
329        InputMap editorInputMap = textComponent.getInputMap();
330        
331        while (editorInputMap != null) {
332            InputMap parent = editorInputMap.getParent();
333            
334            if (parent instanceof UIResource) {
335                installMap(editorInputMap, document.isStrictMatching());
336                break;
337            }
338            
339            editorInputMap = parent;
340        }
341        
342        ActionMap editorActionMap = textComponent.getActionMap();
343        editorActionMap.put("nonstrict-backspace", new NonStrictBackspaceAction(
344                editorActionMap.get(DefaultEditorKit.deletePrevCharAction),
345                editorActionMap.get(DefaultEditorKit.selectionBackwardAction),
346                adaptor));
347    }
348    
349    static void undecorate(JTextComponent textComponent) {
350        Document doc = textComponent.getDocument();
351        
352        if (doc instanceof AutoCompleteDocument) {
353            //remove autocomplete key/action mappings
354            InputMap map = textComponent.getInputMap();
355            
356            while (map.getParent() != null) {
357                InputMap parent = map.getParent();
358                
359                if (parent instanceof AutoComplete.InputMap) {
360                    map.setParent(parent.getParent());
361                }
362                
363                map = parent;
364            }
365            
366            textComponent.getActionMap().put("nonstrict-backspace", null);
367            
368            //remove old focus listener
369            for (FocusListener l : textComponent.getFocusListeners()) {
370                if (l instanceof AutoComplete.FocusAdapter) {
371                    textComponent.removeFocusListener(l);
372                    break;
373                }
374            }
375            
376            //reset to original document
377            textComponent.setDocument(((AutoCompleteDocument) doc).delegate);
378        }
379    }
380    
381    static class NonStrictBackspaceAction extends TextAction {
382        Action backspace;
383        Action selectionBackward;
384        AbstractAutoCompleteAdaptor adaptor;
385        
386        public NonStrictBackspaceAction(Action backspace, Action selectionBackward, AbstractAutoCompleteAdaptor adaptor) {
387            super("nonstrict-backspace");
388            this.backspace = backspace;
389            this.selectionBackward = selectionBackward;
390            this.adaptor = adaptor;
391        }
392        
393        @Override
394        public void actionPerformed(ActionEvent e) {
395            if (adaptor.listContainsSelectedItem()) {
396                selectionBackward.actionPerformed(e);
397            } else {
398                backspace.actionPerformed(e);
399            }
400        }
401    }
402}