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