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