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