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