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