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