1 /*
2  * Copyright (c) 2011, 2017, Oracle and/or its affiliates. All rights reserved.
3  * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4  *
5  * This code is free software; you can redistribute it and/or modify it
6  * under the terms of the GNU General Public License version 2 only, as
7  * published by the Free Software Foundation.  Oracle designates this
8  * particular file as subject to the "Classpath" exception as provided
9  * by Oracle in the LICENSE file that accompanied this code.
10  *
11  * This code is distributed in the hope that it will be useful, but WITHOUT
12  * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13  * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
14  * version 2 for more details (a copy is included in the LICENSE file that
15  * accompanied this code).
16  *
17  * You should have received a copy of the GNU General Public License version
18  * 2 along with this work; if not, write to the Free Software Foundation,
19  * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20  *
21  * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
22  * or visit www.oracle.com if you need additional information or have any
23  * questions.
24  */
25 
26 package com.apple.laf;
27 
28 import java.awt.Component;
29 import java.awt.Dimension;
30 import java.awt.GraphicsConfiguration;
31 import java.awt.GraphicsDevice;
32 import java.awt.GraphicsEnvironment;
33 import java.awt.Insets;
34 import java.awt.Point;
35 import java.awt.Rectangle;
36 import java.awt.Toolkit;
37 import java.awt.event.InputEvent;
38 import java.awt.event.MouseEvent;
39 
40 import javax.swing.Box;
41 import javax.swing.JComboBox;
42 import javax.swing.JList;
43 import javax.swing.ListCellRenderer;
44 import javax.swing.SwingUtilities;
45 import javax.swing.plaf.basic.BasicComboPopup;
46 
47 import sun.lwawt.macosx.CPlatformWindow;
48 
49 @SuppressWarnings("serial") // Superclass is not serializable across versions
50 final class AquaComboBoxPopup extends BasicComboPopup {
51     static final int FOCUS_RING_PAD_LEFT = 6;
52     static final int FOCUS_RING_PAD_RIGHT = 6;
53     static final int FOCUS_RING_PAD_BOTTOM = 5;
54 
55     protected Component topStrut;
56     protected Component bottomStrut;
57     protected boolean isPopDown = false;
58 
AquaComboBoxPopup(final JComboBox<Object> cBox)59     public AquaComboBoxPopup(final JComboBox<Object> cBox) {
60         super(cBox);
61     }
62 
63     @Override
configurePopup()64     protected void configurePopup() {
65         super.configurePopup();
66 
67         setBorderPainted(false);
68         setBorder(null);
69         updateContents(false);
70 
71         // TODO: CPlatformWindow?
72         putClientProperty(CPlatformWindow.WINDOW_FADE_OUT, Integer.valueOf(150));
73     }
74 
updateContents(final boolean remove)75     public void updateContents(final boolean remove) {
76         // for more background on this issue, see AquaMenuBorder.getBorderInsets()
77 
78         isPopDown = isPopdown();
79         if (isPopDown) {
80             if (remove) {
81                 if (topStrut != null) {
82                     this.remove(topStrut);
83                 }
84                 if (bottomStrut != null) {
85                     this.remove(bottomStrut);
86                 }
87             } else {
88                 add(scroller);
89             }
90         } else {
91             if (topStrut == null) {
92                 topStrut = Box.createVerticalStrut(4);
93                 bottomStrut = Box.createVerticalStrut(4);
94             }
95 
96             if (remove) remove(scroller);
97 
98             this.add(topStrut);
99             this.add(scroller);
100             this.add(bottomStrut);
101         }
102     }
103 
getBestPopupSizeForRowCount(final int maxRowCount)104     protected Dimension getBestPopupSizeForRowCount(final int maxRowCount) {
105         final int currentElementCount = comboBox.getModel().getSize();
106         final int rowCount = Math.min(maxRowCount, currentElementCount);
107 
108         final Dimension popupSize = new Dimension();
109         final ListCellRenderer<Object> renderer = list.getCellRenderer();
110 
111         for (int i = 0; i < rowCount; i++) {
112             final Object value = list.getModel().getElementAt(i);
113             final Component c = renderer.getListCellRendererComponent(list, value, i, false, false);
114 
115             final Dimension prefSize = c.getPreferredSize();
116             popupSize.height += prefSize.height;
117             popupSize.width = Math.max(prefSize.width, popupSize.width);
118         }
119 
120         popupSize.width += 10;
121 
122         return popupSize;
123     }
124 
shouldScroll()125     protected boolean shouldScroll() {
126         return comboBox.getItemCount() > comboBox.getMaximumRowCount();
127     }
128 
isPopdown()129     protected boolean isPopdown() {
130         return shouldScroll() || AquaComboBoxUI.isPopdown(comboBox);
131     }
132 
133     @Override
show()134     public void show() {
135         final int startItemCount = comboBox.getItemCount();
136 
137         final Rectangle popupBounds = adjustPopupAndGetBounds();
138         if (popupBounds == null) return; // null means don't show
139 
140         comboBox.firePopupMenuWillBecomeVisible();
141         show(comboBox, popupBounds.x, popupBounds.y);
142 
143         // hack for <rdar://problem/4905531> JComboBox does not fire popupWillBecomeVisible if item count is 0
144         final int afterShowItemCount = comboBox.getItemCount();
145         if (afterShowItemCount == 0) {
146             hide();
147             return;
148         }
149 
150         if (startItemCount != afterShowItemCount) {
151             final Rectangle newBounds = adjustPopupAndGetBounds();
152             list.setSize(newBounds.width, newBounds.height);
153             pack();
154 
155             final Point newLoc = comboBox.getLocationOnScreen();
156             setLocation(newLoc.x + newBounds.x, newLoc.y + newBounds.y);
157         }
158         // end hack
159 
160         list.requestFocusInWindow();
161     }
162 
163     @Override
164     @SuppressWarnings("serial") // anonymous class
createList()165     protected JList<Object> createList() {
166         return new JList<Object>(comboBox.getModel()) {
167             @Override
168             @SuppressWarnings("deprecation")
169             public void processMouseEvent(MouseEvent e) {
170                 if (e.isMetaDown()) {
171                     e = new MouseEvent((Component) e.getSource(), e.getID(),
172                                        e.getWhen(),
173                                        e.getModifiers() ^ InputEvent.META_MASK,
174                                        e.getX(), e.getY(), e.getXOnScreen(),
175                                        e.getYOnScreen(), e.getClickCount(),
176                                        e.isPopupTrigger(), MouseEvent.NOBUTTON);
177                 }
178                 super.processMouseEvent(e);
179             }
180         };
181     }
182 
183     protected Rectangle adjustPopupAndGetBounds() {
184         if (isPopDown != isPopdown()) {
185             updateContents(true);
186         }
187 
188         final Dimension popupSize = getBestPopupSizeForRowCount(comboBox.getMaximumRowCount());
189         final Rectangle popupBounds = computePopupBounds(0, comboBox.getBounds().height, popupSize.width, popupSize.height);
190         if (popupBounds == null) return null; // returning null means don't show anything
191 
192         final Dimension realPopupSize = popupBounds.getSize();
193         scroller.setMaximumSize(realPopupSize);
194         scroller.setPreferredSize(realPopupSize);
195         scroller.setMinimumSize(realPopupSize);
196         list.invalidate();
197 
198         final int selectedIndex = comboBox.getSelectedIndex();
199         if (selectedIndex == -1) {
200             list.clearSelection();
201         } else {
202             list.setSelectedIndex(selectedIndex);
203         }
204         list.ensureIndexIsVisible(list.getSelectedIndex());
205 
206         return popupBounds;
207     }
208 
209     // Get the bounds of the screen where the menu should appear
210     // p is the origin of the combo box in screen bounds
211     Rectangle getBestScreenBounds(final Point p) {
212         //System.err.println("GetBestScreenBounds p: "+ p.x + ", " + p.y);
213         final GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
214         final GraphicsDevice[] gs = ge.getScreenDevices();
215         for (final GraphicsDevice gd : gs) {
216             final GraphicsConfiguration[] gc = gd.getConfigurations();
217             for (final GraphicsConfiguration element0 : gc) {
218                 final Rectangle gcBounds = element0.getBounds();
219                 if (gcBounds.contains(p)) {
220                     return getAvailableScreenArea(gcBounds, element0);
221                 }
222             }
223         }
224 
225         // Hmm.  Origin's off screen, but is any part on?
226         final Rectangle comboBoxBounds = comboBox.getBounds();
227         comboBoxBounds.setLocation(p);
228         for (final GraphicsDevice gd : gs) {
229             final GraphicsConfiguration[] gc = gd.getConfigurations();
230             for (final GraphicsConfiguration element0 : gc) {
231                 final Rectangle gcBounds = element0.getBounds();
232                 if (gcBounds.intersects(comboBoxBounds)) {
233                     return getAvailableScreenArea(gcBounds, element0);
234                 }
235             }
236         }
237 
238         return null;
239     }
240 
241     private Rectangle getAvailableScreenArea(Rectangle bounds,
242                                              GraphicsConfiguration gc) {
243         Insets insets = Toolkit.getDefaultToolkit().getScreenInsets(gc);
244         return new Rectangle(bounds.x + insets.left, bounds.y + insets.top,
245                              bounds.width - insets.left - insets.right,
246                              bounds.height - insets.top - insets.bottom);
247     }
248 
249     private int getComboBoxEdge(int py, boolean bottom) {
250         int offset = bottom ? 9 : -9;
251         // if py is less than new y we have a clipped combo, so leave it alone.
252         return Math.min((py / 2) + offset, py);
253     }
254 
255     @Override
256     protected Rectangle computePopupBounds(int px, int py, int pw, int ph) {
257         final int itemCount = comboBox.getModel().getSize();
258         final boolean isPopdown = isPopdown();
259         final boolean isTableCellEditor = AquaComboBoxUI.isTableCellEditor(comboBox);
260         if (isPopdown && !isTableCellEditor) {
261             // place the popup just below the button, which is
262             // near the center of a large combo box
263             py = getComboBoxEdge(py, true);
264         }
265 
266         // px & py are relative to the combo box
267 
268         // **** Common calculation - applies to the scrolling and menu-style ****
269         final Point p = new Point(0, 0);
270         SwingUtilities.convertPointToScreen(p, comboBox);
271         //System.err.println("First Converting from point to screen: 0,0 is now " + p.x + ", " + p.y);
272         final Rectangle scrBounds = getBestScreenBounds(p);
273         //System.err.println("BestScreenBounds is " + scrBounds);
274 
275         // If the combo box is totally off screen, do whatever super does
276         if (scrBounds == null) return super.computePopupBounds(px, py, pw, ph);
277 
278         // line up with the bottom of the text field/button (or top, if we have to go above it)
279         // and left edge if left-to-right, right edge if right-to-left
280         final Insets comboBoxInsets = comboBox.getInsets();
281         final Rectangle comboBoxBounds = comboBox.getBounds();
282 
283         if (shouldScroll()) {
284             pw += 15;
285         }
286 
287         if (isPopdown) {
288             pw += 4;
289         }
290 
291         // the popup should be wide enough for the items but not wider than the screen it's on
292         final int minWidth = comboBoxBounds.width - (comboBoxInsets.left + comboBoxInsets.right);
293         pw = Math.max(minWidth, pw);
294 
295         final boolean leftToRight = AquaUtils.isLeftToRight(comboBox);
296         if (leftToRight) {
297             px += comboBoxInsets.left;
298             if (!isPopDown) px -= FOCUS_RING_PAD_LEFT;
299         } else {
300             px = comboBoxBounds.width - pw - comboBoxInsets.right;
301             if (!isPopDown) px += FOCUS_RING_PAD_RIGHT;
302         }
303         py -= (comboBoxInsets.bottom); //sja fix was +kInset
304 
305         // Make sure it's all on the screen - shift it by the amount it's off
306         p.x += px;
307         p.y += py; // Screen location of px & py
308         if (p.x < scrBounds.x) {
309             px = px + (scrBounds.x - p.x);
310         }
311         if (p.y < scrBounds.y) {
312             py = py + (scrBounds.y - p.y);
313         }
314 
315         final Point top = new Point(0, 0);
316         SwingUtilities.convertPointFromScreen(top, comboBox);
317         //System.err.println("Converting from point to screen: 0,0 is now " + top.x + ", " + top.y);
318 
319         // Since the popup is at zero in this coord space, the maxWidth == the X coord of the screen right edge
320         // (it might be wider than the screen, if the combo is off the left edge)
321         final int maxWidth = Math.min(scrBounds.width, top.x + scrBounds.x + scrBounds.width) - 2; // subtract some buffer space
322 
323         pw = Math.min(maxWidth, pw);
324         if (pw < minWidth) {
325             px -= (minWidth - pw);
326             pw = minWidth;
327         }
328 
329         // this is a popup window, and will continue calculations below
330         if (!isPopdown) {
331             // popup windows are slightly inset from the combo end-cap
332             pw -= 6;
333             return computePopupBoundsForMenu(px, py, pw, ph, itemCount, scrBounds);
334         }
335 
336         // don't attempt to inset table cell editors
337         if (!isTableCellEditor) {
338             pw -= (FOCUS_RING_PAD_LEFT + FOCUS_RING_PAD_RIGHT);
339             if (leftToRight) {
340                 px += FOCUS_RING_PAD_LEFT;
341             }
342         }
343 
344         final Rectangle r = new Rectangle(px, py, pw, ph);
345         if (r.y + r.height < top.y + scrBounds.y + scrBounds.height) {
346             return r;
347         }
348         // Check whether it goes below the bottom of the screen, if so flip it
349         int newY = getComboBoxEdge(comboBoxBounds.height, false) - ph - comboBoxInsets.top;
350         if (newY > top.y + scrBounds.y) {
351             return new Rectangle(px, newY, r.width, r.height);
352         } else {
353             // There are no place at top, move popup to the center of the screen
354             r.y = top.y + scrBounds.y + Math.max(0, (scrBounds.height - ph) / 2 );
355             r.height = Math.min(scrBounds.height, ph);
356         }
357         return r;
358     }
359 
360     // The one to use when itemCount <= maxRowCount.  Size never adjusts for arrows
361     // We want it positioned so the selected item is right above the combo box
362     protected Rectangle computePopupBoundsForMenu(final int px, final int py,
363                                                   final int pw, final int ph,
364                                                   final int itemCount,
365                                                   final Rectangle scrBounds) {
366         //System.err.println("computePopupBoundsForMenu: " + px + "," + py + " " +  pw + "," + ph);
367         //System.err.println("itemCount: " +itemCount +" src: "+ scrBounds);
368         int elementSize = 0; //kDefaultItemSize;
369         if (list != null && itemCount > 0) {
370             final Rectangle cellBounds = list.getCellBounds(0, 0);
371             if (cellBounds != null) elementSize = cellBounds.height;
372         }
373 
374         int offsetIndex = comboBox.getSelectedIndex();
375         if (offsetIndex < 0) offsetIndex = 0;
376         list.setSelectedIndex(offsetIndex);
377 
378         final int selectedLocation = elementSize * offsetIndex;
379 
380         final Point top = new Point(0, scrBounds.y);
381         final Point bottom = new Point(0, scrBounds.y + scrBounds.height - 20); // Allow some slack
382         SwingUtilities.convertPointFromScreen(top, comboBox);
383         SwingUtilities.convertPointFromScreen(bottom, comboBox);
384 
385         final Rectangle popupBounds = new Rectangle(px, py, pw, ph);// Relative to comboBox
386 
387         final int theRest = ph - selectedLocation;
388 
389         // If the popup fits on the screen and the selection appears under the mouse w/o scrolling, cool!
390         // If the popup won't fit on the screen, adjust its position but not its size
391         // and rewrite this to support arrows - JLists always move the contents so they all show
392 
393         // Test to see if it extends off the screen
394         final boolean extendsOffscreenAtTop = selectedLocation > -top.y;
395         final boolean extendsOffscreenAtBottom = theRest > bottom.y;
396 
397         if (extendsOffscreenAtTop) {
398             popupBounds.y = top.y + 1;
399             // Round it so the selection lines up with the combobox
400             popupBounds.y = (popupBounds.y / elementSize) * elementSize;
401         } else if (extendsOffscreenAtBottom) {
402             // Provide blank space at top for off-screen stuff to scroll into
403             popupBounds.y = bottom.y - popupBounds.height; // popupBounds.height has already been adjusted to fit
404         } else { // fits - position it so the selectedLocation is under the mouse
405             popupBounds.y = -selectedLocation;
406         }
407 
408         // Center the selected item on the combobox
409         final int height = comboBox.getHeight();
410         final Insets insets = comboBox.getInsets();
411         final int buttonSize = height - (insets.top + insets.bottom);
412         final int diff = (buttonSize - elementSize) / 2 + insets.top;
413         popupBounds.y += diff - FOCUS_RING_PAD_BOTTOM;
414 
415         return popupBounds;
416     }
417 }
418