001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.widgets;
003
004import java.awt.Component;
005import java.awt.Dimension;
006import java.awt.event.MouseAdapter;
007import java.awt.event.MouseEvent;
008import java.beans.PropertyChangeEvent;
009import java.beans.PropertyChangeListener;
010import java.util.ArrayList;
011import java.util.Arrays;
012import java.util.Collection;
013import java.util.List;
014
015import javax.accessibility.Accessible;
016import javax.swing.ComboBoxEditor;
017import javax.swing.ComboBoxModel;
018import javax.swing.DefaultComboBoxModel;
019import javax.swing.JComboBox;
020import javax.swing.JList;
021import javax.swing.JTextField;
022import javax.swing.plaf.basic.ComboPopup;
023import javax.swing.text.JTextComponent;
024
025import org.openstreetmap.josm.gui.util.GuiHelper;
026
027/**
028 * Class overriding each {@link JComboBox} in JOSM to control consistently the number of displayed items at once.<br>
029 * This is needed because of the default Java behaviour that may display the top-down list off the screen (see #7917).
030 * @param <E> the type of the elements of this combo box
031 *
032 * @since 5429 (creation)
033 * @since 7015 (generics for Java 7)
034 */
035public class JosmComboBox<E> extends JComboBox<E> {
036
037    /**
038     * Creates a <code>JosmComboBox</code> with a default data model.
039     * The default data model is an empty list of objects.
040     * Use <code>addItem</code> to add items. By default the first item
041     * in the data model becomes selected.
042     *
043     * @see DefaultComboBoxModel
044     */
045    public JosmComboBox() {
046        init(null);
047    }
048
049    /**
050     * Creates a <code>JosmComboBox</code> with a default data model and
051     * the specified prototype display value.
052     * The default data model is an empty list of objects.
053     * Use <code>addItem</code> to add items. By default the first item
054     * in the data model becomes selected.
055     *
056     * @param prototypeDisplayValue the <code>Object</code> used to compute
057     *      the maximum number of elements to be displayed at once before
058     *      displaying a scroll bar
059     *
060     * @see DefaultComboBoxModel
061     * @since 5450
062     */
063    public JosmComboBox(E prototypeDisplayValue) {
064        init(prototypeDisplayValue);
065    }
066
067    /**
068     * Creates a <code>JosmComboBox</code> that takes its items from an
069     * existing <code>ComboBoxModel</code>. Since the
070     * <code>ComboBoxModel</code> is provided, a combo box created using
071     * this constructor does not create a default combo box model and
072     * may impact how the insert, remove and add methods behave.
073     *
074     * @param aModel the <code>ComboBoxModel</code> that provides the
075     *      displayed list of items
076     * @see DefaultComboBoxModel
077     */
078    public JosmComboBox(ComboBoxModel<E> aModel) {
079        super(aModel);
080        List<E> list = new ArrayList<>(aModel.getSize());
081        for (int i = 0; i < aModel.getSize(); i++) {
082            list.add(aModel.getElementAt(i));
083        }
084        init(findPrototypeDisplayValue(list));
085    }
086
087    /**
088     * Creates a <code>JosmComboBox</code> that contains the elements
089     * in the specified array. By default the first item in the array
090     * (and therefore the data model) becomes selected.
091     *
092     * @param items  an array of objects to insert into the combo box
093     * @see DefaultComboBoxModel
094     */
095    public JosmComboBox(E[] items) {
096        super(items);
097        init(findPrototypeDisplayValue(Arrays.asList(items)));
098    }
099
100    /**
101     * Returns the editor component
102     * @return the editor component
103     * @see ComboBoxEditor#getEditorComponent()
104     * @since 9484
105     */
106    public JTextField getEditorComponent() {
107        return (JTextField) getEditor().getEditorComponent();
108    }
109
110    /**
111     * Finds the prototype display value to use among the given possible candidates.
112     * @param possibleValues The possible candidates that will be iterated.
113     * @return The value that needs the largest display height on screen.
114     * @since 5558
115     */
116    protected final E findPrototypeDisplayValue(Collection<E> possibleValues) {
117        E result = null;
118        int maxHeight = -1;
119        if (possibleValues != null) {
120            // Remind old prototype to restore it later
121            E oldPrototype = getPrototypeDisplayValue();
122            // Get internal JList to directly call the renderer
123            @SuppressWarnings("rawtypes")
124            JList list = getList();
125            try {
126                // Index to give to renderer
127                int i = 0;
128                for (E value : possibleValues) {
129                    if (value != null) {
130                        // With a "classic" renderer, we could call setPrototypeDisplayValue(value) + getPreferredSize()
131                        // but not with TaggingPreset custom renderer that return a dummy height if index is equal to -1
132                        // So we explicitly call the renderer by simulating a correct index for the current value
133                        @SuppressWarnings("unchecked")
134                        Component c = getRenderer().getListCellRendererComponent(list, value, i, true, true);
135                        if (c != null) {
136                            // Get the real preferred size for the current value
137                            Dimension dim = c.getPreferredSize();
138                            if (dim.height > maxHeight) {
139                                // Larger ? This is our new prototype
140                                maxHeight = dim.height;
141                                result = value;
142                            }
143                        }
144                    }
145                    i++;
146                }
147            } finally {
148                // Restore original prototype
149                setPrototypeDisplayValue(oldPrototype);
150            }
151        }
152        return result;
153    }
154
155    @SuppressWarnings("unchecked")
156    protected final JList<Object> getList() {
157        for (int i = 0; i < getUI().getAccessibleChildrenCount(this); i++) {
158            Accessible child = getUI().getAccessibleChild(this, i);
159            if (child instanceof ComboPopup) {
160                return ((ComboPopup) child).getList();
161            }
162        }
163        return null;
164    }
165
166    protected final void init(E prototype) {
167        if (prototype != null) {
168            setPrototypeDisplayValue(prototype);
169            int screenHeight = GuiHelper.getScreenSize().height;
170            // Compute maximum number of visible items based on the preferred size of the combo box.
171            // This assumes that items have the same height as the combo box, which is not granted by the look and feel
172            int maxsize = (screenHeight/getPreferredSize().height) / 2;
173            // If possible, adjust the maximum number of items with the real height of items
174            // It is not granted this works on every platform (tested OK on Windows)
175            JList<Object> list = getList();
176            if (list != null) {
177                if (!prototype.equals(list.getPrototypeCellValue())) {
178                    list.setPrototypeCellValue(prototype);
179                }
180                int height = list.getFixedCellHeight();
181                if (height > 0) {
182                    maxsize = (screenHeight/height) / 2;
183                }
184            }
185            setMaximumRowCount(Math.max(getMaximumRowCount(), maxsize));
186        }
187        // Handle text contextual menus for editable comboboxes
188        ContextMenuHandler handler = new ContextMenuHandler();
189        addPropertyChangeListener("editable", handler);
190        addPropertyChangeListener("editor", handler);
191    }
192
193    protected class ContextMenuHandler extends MouseAdapter implements PropertyChangeListener {
194
195        private JTextComponent component;
196        private PopupMenuLauncher launcher;
197
198        @Override
199        public void propertyChange(PropertyChangeEvent evt) {
200            if ("editable".equals(evt.getPropertyName())) {
201                if (evt.getNewValue().equals(Boolean.TRUE)) {
202                    enableMenu();
203                } else {
204                    disableMenu();
205                }
206            } else if ("editor".equals(evt.getPropertyName())) {
207                disableMenu();
208                if (isEditable()) {
209                    enableMenu();
210                }
211            }
212        }
213
214        private void enableMenu() {
215            if (launcher == null && editor != null) {
216                Component editorComponent = editor.getEditorComponent();
217                if (editorComponent instanceof JTextComponent) {
218                    component = (JTextComponent) editorComponent;
219                    component.addMouseListener(this);
220                    launcher = TextContextualPopupMenu.enableMenuFor(component, true);
221                }
222            }
223        }
224
225        private void disableMenu() {
226            if (launcher != null) {
227                TextContextualPopupMenu.disableMenuFor(component, launcher);
228                launcher = null;
229                component.removeMouseListener(this);
230                component = null;
231            }
232        }
233
234        @Override
235        public void mousePressed(MouseEvent e) {
236            processEvent(e);
237        }
238
239        @Override
240        public void mouseReleased(MouseEvent e) {
241            processEvent(e);
242        }
243
244        private void processEvent(MouseEvent e) {
245            if (launcher != null && !e.isPopupTrigger() && launcher.getMenu().isShowing()) {
246                launcher.getMenu().setVisible(false);
247            }
248        }
249    }
250
251    /**
252     * Reinitializes this {@link JosmComboBox} to the specified values. This may needed if a custom renderer is used.
253     * @param values The values displayed in the combo box.
254     * @since 5558
255     */
256    public final void reinitialize(Collection<E> values) {
257        init(findPrototypeDisplayValue(values));
258    }
259}