001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.tagging.ac;
003
004import java.awt.Component;
005import java.awt.Toolkit;
006import java.awt.datatransfer.Clipboard;
007import java.awt.datatransfer.Transferable;
008import java.awt.event.FocusEvent;
009import java.awt.event.FocusListener;
010import java.awt.im.InputContext;
011import java.util.Collection;
012import java.util.Locale;
013
014import javax.swing.ComboBoxEditor;
015import javax.swing.ComboBoxModel;
016import javax.swing.DefaultComboBoxModel;
017import javax.swing.JLabel;
018import javax.swing.JList;
019import javax.swing.ListCellRenderer;
020import javax.swing.text.AttributeSet;
021import javax.swing.text.BadLocationException;
022import javax.swing.text.JTextComponent;
023import javax.swing.text.PlainDocument;
024import javax.swing.text.StyleConstants;
025
026import org.openstreetmap.josm.Main;
027import org.openstreetmap.josm.gui.widgets.JosmComboBox;
028
029/**
030 * Auto-completing ComboBox.
031 * @author guilhem.bonnefille@gmail.com
032 * @since 272
033 */
034public class AutoCompletingComboBox extends JosmComboBox<AutoCompletionListItem> {
035
036    private boolean autocompleteEnabled = true;
037
038    private int maxTextLength = -1;
039    private boolean useFixedLocale;
040
041    /**
042     * Auto-complete a JosmComboBox.
043     * <br>
044     * Inspired by <a href="http://www.orbital-computer.de/JComboBox">Thomas Bierhance example</a>.
045     */
046    class AutoCompletingComboBoxDocument extends PlainDocument {
047        private JosmComboBox<AutoCompletionListItem> comboBox;
048        private boolean selecting = false;
049
050        /**
051         * Constructs a new {@code AutoCompletingComboBoxDocument}.
052         * @param comboBox the combobox
053         */
054        public AutoCompletingComboBoxDocument(final JosmComboBox<AutoCompletionListItem> comboBox) {
055            this.comboBox = comboBox;
056        }
057
058        @Override
059        public void remove(int offs, int len) throws BadLocationException {
060            if (selecting)
061                return;
062            super.remove(offs, len);
063        }
064
065        @Override
066        public void insertString(int offs, String str, AttributeSet a) throws BadLocationException {
067            if (selecting || (offs == 0 && str.equals(getText(0, getLength()))))
068                return;
069            if (maxTextLength > -1 && str.length()+getLength() > maxTextLength)
070                return;
071            boolean initial = offs == 0 && getLength() == 0 && str.length() > 1;
072            super.insertString(offs, str, a);
073
074            // return immediately when selecting an item
075            // Note: this is done after calling super method because we need
076            // ActionListener informed
077            if (selecting)
078                return;
079            if (!autocompleteEnabled)
080                return;
081            // input method for non-latin characters (e.g. scim)
082            if (a != null && a.isDefined(StyleConstants.ComposedTextAttribute))
083                return;
084
085            int size = getLength();
086            int start = offs+str.length();
087            int end = start;
088            String curText = getText(0, size);
089
090            // item for lookup and selection
091            Object item = null;
092            // if the text is a number we don't autocomplete
093            if (Main.pref.getBoolean("autocomplete.dont_complete_numbers", true)) {
094                try {
095                    Long.parseLong(str);
096                    if (curText.length() != 0)
097                        Long.parseLong(curText);
098                    item = lookupItem(curText, true);
099                } catch (NumberFormatException e) {
100                    // either the new text or the current text isn't a number. We continue with
101                    // autocompletion
102                    item = lookupItem(curText, false);
103                }
104            } else {
105                item = lookupItem(curText, false);
106            }
107
108            setSelectedItem(item);
109            if (initial) {
110                start = 0;
111            }
112            if (item != null) {
113                String newText = ((AutoCompletionListItem) item).getValue();
114                if (!newText.equals(curText)) {
115                    selecting = true;
116                    super.remove(0, size);
117                    super.insertString(0, newText, a);
118                    selecting = false;
119                    start = size;
120                    end = getLength();
121                }
122            }
123            JTextComponent editorComponent = (JTextComponent)comboBox.getEditor().getEditorComponent();
124            // save unix system selection (middle mouse paste)
125            Clipboard sysSel = Toolkit.getDefaultToolkit().getSystemSelection();
126            if(sysSel != null) {
127                Transferable old = sysSel.getContents(null);
128                editorComponent.select(start, end);
129                sysSel.setContents(old, null);
130            } else {
131                editorComponent.select(start, end);
132            }
133        }
134
135        private void setSelectedItem(Object item) {
136            selecting = true;
137            comboBox.setSelectedItem(item);
138            selecting = false;
139        }
140
141        private Object lookupItem(String pattern, boolean match) {
142            ComboBoxModel<AutoCompletionListItem> model = comboBox.getModel();
143            AutoCompletionListItem bestItem = null;
144            for (int i = 0, n = model.getSize(); i < n; i++) {
145                AutoCompletionListItem currentItem = model.getElementAt(i);
146                if (currentItem.getValue().equals(pattern))
147                    return currentItem;
148                if (!match && currentItem.getValue().startsWith(pattern)
149                && (bestItem == null || currentItem.getPriority().compareTo(bestItem.getPriority()) > 0)) {
150                    bestItem = currentItem;
151                }
152            }
153            return bestItem; // may be null
154        }
155    }
156
157    /**
158     * Creates a <code>AutoCompletingComboBox</code> with a default prototype display value.
159     */
160    public AutoCompletingComboBox() {
161        this("Foo");
162    }
163
164    /**
165     * Creates a <code>AutoCompletingComboBox</code> with the specified prototype display value.
166     * @param prototype the <code>Object</code> used to compute the maximum number of elements to be displayed at once
167     *                  before displaying a scroll bar. It also affects the initial width of the combo box.
168     * @since 5520
169     */
170    public AutoCompletingComboBox(String prototype) {
171        super(new AutoCompletionListItem(prototype));
172        setRenderer(new AutoCompleteListCellRenderer());
173        final JTextComponent editorComponent = (JTextComponent) this.getEditor().getEditorComponent();
174        editorComponent.setDocument(new AutoCompletingComboBoxDocument(this));
175        editorComponent.addFocusListener(
176                new FocusListener() {
177                    @Override
178                    public void focusLost(FocusEvent e) {
179                        if (Main.map != null) {
180                            Main.map.keyDetector.setEnabled(true);
181                        }
182                    }
183                    @Override
184                    public void focusGained(FocusEvent e) {
185                        if (Main.map != null) {
186                            Main.map.keyDetector.setEnabled(false);
187                        }
188                        // save unix system selection (middle mouse paste)
189                        Clipboard sysSel = Toolkit.getDefaultToolkit().getSystemSelection();
190                        if(sysSel != null) {
191                            Transferable old = sysSel.getContents(null);
192                            editorComponent.selectAll();
193                            sysSel.setContents(old, null);
194                        } else {
195                            editorComponent.selectAll();
196                        }
197                    }
198                }
199        );
200    }
201
202    /**
203     * Sets the maximum text length.
204     * @param length the maximum text length in number of characters
205     */
206    public void setMaxTextLength(int length) {
207        this.maxTextLength = length;
208    }
209
210    /**
211     * Convert the selected item into a String that can be edited in the editor component.
212     *
213     * @param cbEditor    the editor
214     * @param item      excepts AutoCompletionListItem, String and null
215     */
216    @Override
217    public void configureEditor(ComboBoxEditor cbEditor, Object item) {
218        if (item == null) {
219            cbEditor.setItem(null);
220        } else if (item instanceof String) {
221            cbEditor.setItem(item);
222        } else if (item instanceof AutoCompletionListItem) {
223            cbEditor.setItem(((AutoCompletionListItem)item).getValue());
224        } else
225            throw new IllegalArgumentException();
226    }
227
228    /**
229     * Selects a given item in the ComboBox model
230     * @param item      excepts AutoCompletionListItem, String and null
231     */
232    @Override
233    public void setSelectedItem(Object item) {
234        if (item == null) {
235            super.setSelectedItem(null);
236        } else if (item instanceof AutoCompletionListItem) {
237            super.setSelectedItem(item);
238        } else if (item instanceof String) {
239            String s = (String) item;
240            // find the string in the model or create a new item
241            for (int i=0; i< getModel().getSize(); i++) {
242                AutoCompletionListItem acItem = getModel().getElementAt(i);
243                if (s.equals(acItem.getValue())) {
244                    super.setSelectedItem(acItem);
245                    return;
246                }
247            }
248            super.setSelectedItem(new AutoCompletionListItem(s, AutoCompletionItemPriority.UNKNOWN));
249        } else
250            throw new IllegalArgumentException();
251    }
252
253    /**
254     * Sets the items of the combobox to the given {@code String}s.
255     * @param elems String items
256     */
257    public void setPossibleItems(Collection<String> elems) {
258        DefaultComboBoxModel<AutoCompletionListItem> model = (DefaultComboBoxModel<AutoCompletionListItem>)this.getModel();
259        Object oldValue = this.getEditor().getItem(); // Do not use getSelectedItem(); (fix #8013)
260        model.removeAllElements();
261        for (String elem : elems) {
262            model.addElement(new AutoCompletionListItem(elem, AutoCompletionItemPriority.UNKNOWN));
263        }
264        // disable autocomplete to prevent unnecessary actions in AutoCompletingComboBoxDocument#insertString
265        autocompleteEnabled = false;
266        this.getEditor().setItem(oldValue); // Do not use setSelectedItem(oldValue); (fix #8013)
267        autocompleteEnabled = true;
268    }
269
270    /**
271     * Sets the items of the combobox to the given {@code AutoCompletionListItem}s.
272     * @param elems AutoCompletionListItem items
273     */
274    public void setPossibleACItems(Collection<AutoCompletionListItem> elems) {
275        DefaultComboBoxModel<AutoCompletionListItem> model = (DefaultComboBoxModel<AutoCompletionListItem>)this.getModel();
276        Object oldValue = getSelectedItem();
277        Object editorOldValue = this.getEditor().getItem();
278        model.removeAllElements();
279        for (AutoCompletionListItem elem : elems) {
280            model.addElement(elem);
281        }
282        setSelectedItem(oldValue);
283        this.getEditor().setItem(editorOldValue);
284    }
285
286    /**
287     * Determines if autocompletion is enabled.
288     * @return {@code true} if autocompletion is enabled, {@code false} otherwise.
289     */
290    public final boolean isAutocompleteEnabled() {
291        return autocompleteEnabled;
292    }
293
294    protected void setAutocompleteEnabled(boolean autocompleteEnabled) {
295        this.autocompleteEnabled = autocompleteEnabled;
296    }
297
298    /**
299     * If the locale is fixed, English keyboard layout will be used by default for this combobox
300     * all other components can still have different keyboard layout selected
301     * @param f fixed locale
302     */
303    public void setFixedLocale(boolean f) {
304        useFixedLocale = f;
305        if (useFixedLocale) {
306            privateInputContext.selectInputMethod(new Locale("en", "US"));
307        }
308    }
309
310    private static InputContext privateInputContext = InputContext.getInstance();
311
312    @Override
313    public InputContext getInputContext() {
314        if (useFixedLocale) {
315            return privateInputContext;
316        }
317        return super.getInputContext();
318    }
319
320    /**
321     * ListCellRenderer for AutoCompletingComboBox
322     * renders an AutoCompletionListItem by showing only the string value part
323     */
324    public static class AutoCompleteListCellRenderer extends JLabel implements ListCellRenderer<AutoCompletionListItem> {
325
326        /**
327         * Constructs a new {@code AutoCompleteListCellRenderer}.
328         */
329        public AutoCompleteListCellRenderer() {
330            setOpaque(true);
331        }
332
333        @Override
334        public Component getListCellRendererComponent(
335                JList<? extends AutoCompletionListItem> list,
336                AutoCompletionListItem item,
337                int index,
338                boolean isSelected,
339                boolean cellHasFocus) {
340            if (isSelected) {
341                setBackground(list.getSelectionBackground());
342                setForeground(list.getSelectionForeground());
343            } else {
344                setBackground(list.getBackground());
345                setForeground(list.getForeground());
346            }
347
348            setText(item.getValue());
349            return this;
350        }
351    }
352}