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}