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