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