001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.widgets; 003 004 005import java.awt.Component; 006import java.awt.ComponentOrientation; 007import java.awt.Dimension; 008import java.awt.Graphics; 009import java.awt.GraphicsConfiguration; 010import java.awt.Insets; 011import java.awt.Point; 012import java.awt.Rectangle; 013import java.awt.Toolkit; 014import java.beans.PropertyChangeEvent; 015import java.beans.PropertyChangeListener; 016 017import javax.swing.ComboBoxEditor; 018import javax.swing.JComboBox; 019import javax.swing.JList; 020import javax.swing.JScrollPane; 021import javax.swing.JTextField; 022import javax.swing.ListCellRenderer; 023import javax.swing.border.Border; 024import javax.swing.event.PopupMenuEvent; 025import javax.swing.event.PopupMenuListener; 026import javax.swing.text.JTextComponent; 027 028import org.openstreetmap.josm.spi.preferences.Config; 029 030/** 031 * Base class for all comboboxes in JOSM. 032 * <p> 033 * This combobox will show as many rows as possible without covering the combox itself. It makes 034 * sure the list will never go outside the screen (see #7917). You may limit the number of rows 035 * shown with the configuration: {@code gui.combobox.maximum-row-count}. 036 * <p> 037 * This combobox uses a {@link JosmTextField} for its editor component. 038 * 039 * @param <E> the type of the elements of this combo box 040 * @since 5429 (creation) 041 * @since 7015 (generics for Java 7) 042 */ 043public class JosmComboBox<E> extends JComboBox<E> implements PopupMenuListener, PropertyChangeListener { 044 /** 045 * Limits the number of rows that this combobox will show. 046 */ 047 public static final String PROP_MAXIMUM_ROW_COUNT = "gui.combobox.maximum-row-count"; 048 049 /** the configured maximum row count or null */ 050 private Integer configMaximumRowCount; 051 052 /** 053 * The preferred height of the combobox when closed. Use if the items in the list dropdown are 054 * taller than the item in the editor, as in some comboboxes in the preset dialog. -1 to use 055 * the height of the tallest item in the list. 056 */ 057 private int preferredHeight = -1; 058 059 /** greyed text to display in the editor when the selected value is empty */ 060 private String hint; 061 062 /** 063 * Creates a {@code JosmComboBox} with a {@link JosmComboBoxModel} data model. 064 * The default data model is an empty list of objects. 065 * Use <code>addItem</code> to add items. By default the first item 066 * in the data model becomes selected. 067 */ 068 public JosmComboBox() { 069 super(new JosmComboBoxModel<E>()); 070 init(); 071 } 072 073 /** 074 * Creates a {@code JosmComboBox} with a {@link JosmComboBoxModel} data model and 075 * the specified prototype display value. 076 * The default data model is an empty list of objects. 077 * Use <code>addItem</code> to add items. By default the first item 078 * in the data model becomes selected. 079 * 080 * @param prototypeDisplayValue the <code>Object</code> used to compute 081 * the maximum number of elements to be displayed at once before 082 * displaying a scroll bar 083 * 084 * @since 5450 085 * @deprecated use {@link #setPrototypeDisplayValue} instead. 086 */ 087 @Deprecated 088 public JosmComboBox(E prototypeDisplayValue) { 089 super(new JosmComboBoxModel<E>()); 090 setPrototypeDisplayValue(prototypeDisplayValue); 091 init(); 092 } 093 094 /** 095 * Creates a {@code JosmComboBox} that takes it items from an existing {@link JosmComboBoxModel} 096 * data model. 097 * 098 * @param aModel the model that provides the displayed list of items 099 */ 100 public JosmComboBox(JosmComboBoxModel<E> aModel) { 101 super(aModel); 102 init(); 103 } 104 105 /** 106 * Creates a {@code JosmComboBox} that takes it items from an existing {@link JosmComboBoxModel} 107 * data model and sets the specified prototype display value. 108 * 109 * @param aModel the model that provides the displayed list of items 110 * @param prototypeDisplayValue use this item to size the combobox (may be null) 111 * @deprecated use {@link #setPrototypeDisplayValue} instead. 112 */ 113 @Deprecated 114 public JosmComboBox(JosmComboBoxModel<E> aModel, E prototypeDisplayValue) { 115 super(aModel); 116 setPrototypeDisplayValue(prototypeDisplayValue); 117 init(); 118 } 119 120 /** 121 * Creates a {@code JosmComboBox} that contains the elements 122 * in the specified array. By default the first item in the array 123 * (and therefore the data model) becomes selected. 124 * 125 * @param items an array of objects to insert into the combo box 126 */ 127 public JosmComboBox(E[] items) { 128 super(new JosmComboBoxModel<E>()); 129 init(); 130 for (E elem : items) { 131 getModel().addElement(elem); 132 } 133 } 134 135 private void init() { 136 configMaximumRowCount = Config.getPref().getInt(PROP_MAXIMUM_ROW_COUNT, 9999); 137 setEditor(new JosmComboBoxEditor()); 138 // listen when the popup shows up so we can maximize its height 139 addPopupMenuListener(this); 140 } 141 142 /** 143 * Returns the {@link JosmComboBoxModel} currently used. 144 * 145 * @return the model or null 146 */ 147 @Override 148 public JosmComboBoxModel<E> getModel() { 149 return (JosmComboBoxModel<E>) dataModel; 150 } 151 152 @Override 153 public void setEditor(ComboBoxEditor newEditor) { 154 if (editor != null) { 155 editor.getEditorComponent().removePropertyChangeListener(this); 156 } 157 super.setEditor(newEditor); 158 if (editor != null) { 159 // listen to orientation changes in the editor 160 editor.getEditorComponent().addPropertyChangeListener(this); 161 } 162 } 163 164 /** 165 * Returns the editor component 166 * @return the editor component 167 * @see ComboBoxEditor#getEditorComponent() 168 * @since 9484 169 */ 170 public JosmTextField getEditorComponent() { 171 return (JosmTextField) (editor == null ? null : editor.getEditorComponent()); 172 } 173 174 /** 175 * Returns the string representation of current edited item, or null. 176 * @return the string representation of current edited item, or null 177 * @since 18313 178 */ 179 public String getEditorItemAsString() { 180 return editor != null && editor.getItem() != null ? editor.getItem().toString() : null; 181 } 182 183 /** 184 * Returns the text in the combobox editor. 185 * @return the text 186 * @see JTextComponent#getText 187 * @since 18173 188 */ 189 public String getText() { 190 JosmTextField tf = getEditorComponent(); 191 return tf == null ? null : tf.getText(); 192 } 193 194 /** 195 * Sets the text in the combobox editor. 196 * @param value the text to set 197 * @see JTextComponent#setText 198 * @since 18173 199 */ 200 public void setText(String value) { 201 JosmTextField tf = getEditorComponent(); 202 if (tf != null) 203 tf.setText(value); 204 } 205 206 /** 207 * Selects an item and/or sets text 208 * 209 * Selects the item whose {@code toString()} equals {@code text}. If an item could not be found, 210 * selects nothing and sets the text anyway. 211 * 212 * @param text the text to select and set 213 * @return the item or null 214 */ 215 public E setSelectedItemText(String text) { 216 E item = getModel().find(text); 217 setSelectedItem(item); 218 if (text == null || !text.equals(getText())) 219 setText(text); 220 return item; 221 } 222 223 /* Hint handling */ 224 225 /** 226 * Returns the hint text 227 * @return the hint text 228 */ 229 public String getHint() { 230 return hint; 231 } 232 233 /** 234 * Sets the hint to display when no text has been entered. 235 * 236 * @param hint the hint to set 237 * @return the old hint 238 * @since 18221 239 */ 240 public String setHint(String hint) { 241 String old = hint; 242 this.hint = hint; 243 JosmTextField tf = getEditorComponent(); 244 if (tf != null) 245 tf.setHint(hint); 246 return old; 247 } 248 249 @Override 250 public void setComponentOrientation(ComponentOrientation o) { 251 if (o.isLeftToRight() != getComponentOrientation().isLeftToRight()) { 252 super.setComponentOrientation(o); 253 getEditorComponent().setComponentOrientation(o); 254 // the button doesn't move over without this 255 revalidate(); 256 } 257 } 258 259 /** 260 * Return true if the combobox should display the hint text. 261 * 262 * @return whether to display the hint text 263 * @since 18221 264 */ 265 public boolean displayHint() { 266 return !isEditable() && hint != null && !hint.isEmpty() && getText().isEmpty(); // && !isFocusOwner(); 267 } 268 269 /** 270 * Overrides the calculated height. See: {@link #setPreferredHeight(int)}. 271 * 272 * @since 18221 273 */ 274 @Override 275 public Dimension getPreferredSize() { 276 Dimension d = super.getPreferredSize(); 277 if (preferredHeight != -1) 278 d.height = preferredHeight; 279 return d; 280 } 281 282 /** 283 * Sets the preferred height of the combobox editor. 284 * <p> 285 * A combobox editor is automatically sized to accomodate the widest and the tallest items in 286 * the list. In the Preset dialogs we show more of an item in the list than in the editor, so 287 * the editor becomes too big. With this method we can set the editor height to a fixed value. 288 * <p> 289 * Set this to -1 to get the default behaviour back. 290 * 291 * See also: #6157 292 * 293 * @param height the preferred height or -1 294 * @return the old preferred height 295 * @see #setPreferredSize 296 * @since 18221 297 */ 298 public int setPreferredHeight(int height) { 299 int old = preferredHeight; 300 preferredHeight = height; 301 return old; 302 } 303 304 /** 305 * Get the dropdown list component 306 * 307 * @return the list or null 308 */ 309 @SuppressWarnings("rawtypes") 310 public JList getList() { 311 Object popup = getUI().getAccessibleChild(this, 0); 312 if (popup != null && popup instanceof javax.swing.plaf.basic.ComboPopup) { 313 return ((javax.swing.plaf.basic.ComboPopup) popup).getList(); 314 } 315 return null; 316 } 317 318 /** 319 * Draw the hint text for read-only comboboxes. 320 * <p> 321 * The obvious way -- to call {@code setText(hint)} and {@code setForeground(gray)} on the 322 * {@code JLabel} returned by the list cell renderer -- unfortunately does not work out well 323 * because many UIs change the foreground color or the enabled state of the {@code JLabel} after 324 * the list cell renderer has returned ({@code BasicComboBoxUI}). Other UIs don't honor the 325 * label color at all ({@code SynthLabelUI}). 326 * <p> 327 * We use the same approach as in {@link JosmTextField}. The only problem we face is to get the 328 * coordinates of the text inside the combobox. Fortunately even read-only comboboxes have a 329 * (partially configured) editor component, although they don't use it. We configure that editor 330 * just enough to call {@link JTextField#modelToView modelToView} and 331 * {@link javax.swing.JComponent#getBaseline getBaseline} on it, thus obtaining the text 332 * coordinates. 333 * 334 * @see javax.swing.plaf.basic.BasicComboBoxUI#paintCurrentValue 335 * @see javax.swing.plaf.synth.SynthLabelUI#paint 336 */ 337 @Override 338 protected void paintComponent(Graphics g) { 339 super.paintComponent(g); 340 JosmTextField editor = getEditorComponent(); 341 if (displayHint() && editor != null) { 342 if (editor.getSize().width == 0) { 343 Dimension dimen = getSize(); 344 Insets insets = getInsets(); 345 // a fake configuration not too far from reality 346 editor.setSize(dimen.width - insets.left - insets.right, 347 dimen.height - insets.top - insets.bottom); 348 } 349 editor.drawHint(g); 350 } 351 } 352 353 /** 354 * Empties the internal undo manager, if any. 355 * <p> 356 * Used in the {@link org.openstreetmap.josm.gui.io.UploadDialog UploadDialog}. 357 * @since 14977 358 */ 359 public final void discardAllUndoableEdits() { 360 getEditorComponent().discardAllUndoableEdits(); 361 } 362 363 /** 364 * Limits the popup height. 365 * <p> 366 * Limits the popup height to the available screen space either below or above the combobox, 367 * whichever is bigger. To find the maximum number of rows that fit the screen, it does the 368 * reverse of the calculation done in 369 * {@link javax.swing.plaf.basic.BasicComboPopup#getPopupLocation}. 370 * 371 * @see javax.swing.plaf.basic.BasicComboBoxUI#getAccessibleChild 372 */ 373 @Override 374 public void popupMenuWillBecomeVisible(PopupMenuEvent ev) { 375 // Get the combobox bounds. 376 Rectangle bounds = new Rectangle(getLocationOnScreen(), getSize()); 377 378 // Get the screen bounds of the screen (of a multi-screen setup) we are on. 379 Rectangle screenBounds; 380 GraphicsConfiguration gc = getGraphicsConfiguration(); 381 Toolkit toolkit = Toolkit.getDefaultToolkit(); 382 if (gc != null) { 383 Insets screenInsets = toolkit.getScreenInsets(gc); 384 screenBounds = gc.getBounds(); 385 screenBounds.x += screenInsets.left; 386 screenBounds.y += screenInsets.top; 387 screenBounds.width -= (screenInsets.left + screenInsets.right); 388 screenBounds.height -= (screenInsets.top + screenInsets.bottom); 389 } else { 390 screenBounds = new Rectangle(new Point(), toolkit.getScreenSize()); 391 } 392 int freeAbove = bounds.y - screenBounds.y; 393 int freeBelow = (screenBounds.y + screenBounds.height) - (bounds.y + bounds.height); 394 395 try { 396 // First try an implementation-dependent method to get the exact number. 397 @SuppressWarnings("unchecked") 398 JList<E> jList = getList(); 399 400 // Calculate the free space available on screen 401 Insets insets = jList.getInsets(); 402 // A small fudge factor that accounts for the displacement of the popup relative to the 403 // combobox and the popup shadow. 404 int fudge = 4; 405 int free = Math.max(freeAbove, freeBelow) - (insets.top + insets.bottom) - fudge; 406 if (jList.getParent() instanceof JScrollPane) { 407 JScrollPane scroller = (JScrollPane) jList.getParent(); 408 Border border = scroller.getViewportBorder(); 409 if (border != null) { 410 insets = border.getBorderInsets(null); 411 free -= insets.top + insets.bottom; 412 } 413 border = scroller.getBorder(); 414 if (border != null) { 415 insets = border.getBorderInsets(null); 416 free -= insets.top + insets.bottom; 417 } 418 } 419 420 // Calculate how many rows fit into the free space. Rows may have variable heights. 421 int rowCount = Math.min(configMaximumRowCount, getItemCount()); 422 ListCellRenderer<? super E> r = jList.getCellRenderer(); // must take this from list, not combo: flatlaf bug 423 int i, h = 0; 424 for (i = 0; i < rowCount; ++i) { 425 Component c = r.getListCellRendererComponent(jList, getModel().getElementAt(i), i, false, false); 426 h += c.getPreferredSize().height; 427 if (h >= free) 428 break; 429 } 430 setMaximumRowCount(i); 431 // Logging.debug("free = {0}, h = {1}, i = {2}, bounds = {3}, screenBounds = {4}", free, h, i, bounds, screenBounds); 432 } catch (Exception ex) { 433 setMaximumRowCount(8); // the default 434 } 435 } 436 437 @Override 438 public void popupMenuWillBecomeInvisible(PopupMenuEvent e) { 439 // Who cares? 440 } 441 442 @Override 443 public void popupMenuCanceled(PopupMenuEvent e) { 444 // Who cares? 445 } 446 447 @Override 448 public void propertyChange(PropertyChangeEvent evt) { 449 // follow our editor's orientation 450 if ("componentOrientation".equals(evt.getPropertyName())) { 451 setComponentOrientation((ComponentOrientation) evt.getNewValue()); 452 } 453 } 454}