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
26package com.apple.laf;
27
28import java.awt.Component;
29import java.awt.Dimension;
30import java.awt.GraphicsConfiguration;
31import java.awt.GraphicsDevice;
32import java.awt.GraphicsEnvironment;
33import java.awt.Insets;
34import java.awt.Point;
35import java.awt.Rectangle;
36import java.awt.Toolkit;
37import java.awt.event.InputEvent;
38import java.awt.event.MouseEvent;
39
40import javax.swing.Box;
41import javax.swing.JComboBox;
42import javax.swing.JList;
43import javax.swing.ListCellRenderer;
44import javax.swing.SwingUtilities;
45import javax.swing.plaf.basic.BasicComboPopup;
46
47import sun.lwawt.macosx.CPlatformWindow;
48
49@SuppressWarnings("serial") // Superclass is not serializable across versions
50final 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
59    public AquaComboBoxPopup(final JComboBox<Object> cBox) {
60        super(cBox);
61    }
62
63    @Override
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
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
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
125    protected boolean shouldScroll() {
126        return comboBox.getItemCount() > comboBox.getMaximumRowCount();
127    }
128
129    protected boolean isPopdown() {
130        return shouldScroll() || AquaComboBoxUI.isPopdown(comboBox);
131    }
132
133    @Override
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
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