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