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