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}