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