001/*
002 * This file is part of McIDAS-V
003 *
004 * Copyright 2007-2016
005 * Space Science and Engineering Center (SSEC)
006 * University of Wisconsin - Madison
007 * 1225 W. Dayton Street, Madison, WI 53706, USA
008 * https://www.ssec.wisc.edu/mcidas
009 * 
010 * All Rights Reserved
011 * 
012 * McIDAS-V is built on Unidata's IDV and SSEC's VisAD libraries, and
013 * some McIDAS-V source code is based on IDV and VisAD source code.  
014 * 
015 * McIDAS-V is free software; you can redistribute it and/or modify
016 * it under the terms of the GNU Lesser Public License as published by
017 * the Free Software Foundation; either version 3 of the License, or
018 * (at your option) any later version.
019 * 
020 * McIDAS-V is distributed in the hope that it will be useful,
021 * but WITHOUT ANY WARRANTY; without even the implied warranty of
022 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
023 * GNU Lesser Public License for more details.
024 * 
025 * You should have received a copy of the GNU Lesser Public License
026 * along with this program.  If not, see http://www.gnu.org/licenses.
027 */
028
029package edu.wisc.ssec.mcidasv.ui;
030
031import java.awt.Color;
032import java.awt.Component;
033import java.awt.Dimension;
034import java.awt.Graphics;
035import java.awt.GraphicsConfiguration;
036import java.awt.Insets;
037import java.awt.Rectangle;
038import java.awt.event.ActionEvent;
039import java.awt.event.ActionListener;
040import java.awt.event.MouseWheelEvent;
041import java.awt.event.MouseWheelListener;
042import java.util.Arrays;
043import java.util.Objects;
044
045import javax.swing.Icon;
046import javax.swing.JComponent;
047import javax.swing.JMenu;
048import javax.swing.JMenuItem;
049import javax.swing.JPopupMenu;
050import javax.swing.JSeparator;
051import javax.swing.Timer;
052import javax.swing.event.ChangeEvent;
053import javax.swing.event.ChangeListener;
054import javax.swing.event.PopupMenuEvent;
055import javax.swing.event.PopupMenuListener;
056
057
058/**
059 * A class that provides scrolling capabilities to a long menu dropdown or
060 * popup menu. A number of items can optionally be frozen at the top of the menu.
061 * <p>
062 * <b>Implementation note:</b>  The default scrolling interval is 150 milliseconds.
063 * <p>
064 * @author Darryl, https://tips4java.wordpress.com/2009/02/01/menu-scroller/
065 * @since 4593
066 *
067 * MenuScroller.java    1.5.0 04/02/12
068 * License: use / modify without restrictions (see https://tips4java.wordpress.com/about/)
069 * Heavily modified for JOSM needs =&gt; drop unused features and replace static scrollcount approach by dynamic behaviour
070 */
071public class MenuScroller {
072
073    private JComponent parent;
074    private JPopupMenu menu;
075    private Component[] menuItems;
076    private MenuScrollItem upItem;
077    private MenuScrollItem downItem;
078    private final MenuScrollListener menuListener = new MenuScrollListener();
079    private final MouseWheelListener mouseWheelListener = new MouseScrollListener();
080    private int interval;
081    private int topFixedCount;
082    private int firstIndex = 0;
083
084    private static final int ARROW_ICON_HEIGHT = 10;
085
086    /**
087     * Computes the maximum dimension for a component to fit in screen
088     * displaying {@code component}.
089     *
090     * @param component The component to get current screen info from.
091     * Must not be {@code null}
092     *
093     * @return Maximum dimension for a component to fit in current screen.
094     *
095     * @throws NullPointerException if {@code component} is {@code null}.
096     */
097    public static Dimension getMaxDimensionOnScreen(JComponent parent, JComponent component) {
098        Objects.requireNonNull(component, "component");
099        // Compute max dimension of current screen
100        Dimension result = new Dimension();
101        GraphicsConfiguration gc = component.getGraphicsConfiguration();
102        if ((gc == null) && (parent != null)) {
103            gc = parent.getGraphicsConfiguration();
104        }
105        if (gc != null) {
106            // Max displayable dimension (max screen dimension - insets)
107            Rectangle bounds = gc.getBounds();
108            Insets insets = component.getToolkit().getScreenInsets(gc);
109            result.width  = bounds.width  - insets.left - insets.right;
110            result.height = bounds.height - insets.top - insets.bottom;
111        }
112        return result;
113    }
114
115    private int computeScrollCount(int startIndex) {
116        int result = 15;
117        if (menu != null) {
118            // Compute max height of current screen
119//            Component parent = IdvWindow.getActiveWindow().getFrame();
120            int maxHeight = getMaxDimensionOnScreen(parent, menu).height - parent.getInsets().top;
121
122            // Remove top fixed part height
123            if (topFixedCount > 0) {
124                for (int i = 0; i < topFixedCount; i++) {
125                    maxHeight -= menuItems[i].getPreferredSize().height;
126                }
127                maxHeight -= new JSeparator().getPreferredSize().height;
128            }
129
130            // Remove height of our two arrow items + insets
131            maxHeight -= menu.getInsets().top;
132            maxHeight -= upItem.getPreferredSize().height;
133            maxHeight -= downItem.getPreferredSize().height;
134            maxHeight -= menu.getInsets().bottom;
135
136            // Compute scroll count
137            result = 0;
138            int height = 0;
139            for (int i = startIndex; (i < menuItems.length) && (height <= maxHeight); i++, result++) {
140                height += menuItems[i].getPreferredSize().height;
141            }
142
143            if (height > maxHeight) {
144                // Remove extra item from count
145                result--;
146            } else {
147                // Increase scroll count to take into account upper items that will be displayed
148                // after firstIndex is updated
149                for (int i = startIndex-1; (i >= 0) && (height <= maxHeight); i--, result++) {
150                    height += menuItems[i].getPreferredSize().height;
151                }
152                if (height > maxHeight) {
153                    result--;
154                }
155            }
156        }
157        return result;
158    }
159
160    /**
161     * Registers a menu to be scrolled with the default scrolling interval.
162     *
163     * @param menu Menu to
164     * @return the MenuScroller
165     */
166    public static MenuScroller setScrollerFor(JMenu menu) {
167        return new MenuScroller(menu);
168    }
169
170    /**
171     * Registers a popup menu to be scrolled with the default scrolling interval.
172     *
173     * @param menu the popup menu
174     * @return the MenuScroller
175     */
176    public static MenuScroller setScrollerFor(JPopupMenu menu) {
177        return new MenuScroller(menu);
178    }
179
180    /**
181     * Registers a menu to be scrolled, with the specified scrolling interval.
182     *
183     * @param menu the menu
184     * @param interval the scroll interval, in milliseconds
185     * @return the MenuScroller
186     * @throws IllegalArgumentException if scrollCount or interval is 0 or negative
187     */
188    public static MenuScroller setScrollerFor(JMenu menu, int interval) {
189        return new MenuScroller(menu, interval);
190    }
191
192    /**
193     * Registers a popup menu to be scrolled, with the specified scrolling interval.
194     *
195     * @param menu the popup menu
196     * @param interval the scroll interval, in milliseconds
197     * @return the MenuScroller
198     * @throws IllegalArgumentException if scrollCount or interval is 0 or negative
199     */
200    public static MenuScroller setScrollerFor(JPopupMenu menu, int interval) {
201        return new MenuScroller(menu, interval);
202    }
203
204    /**
205     * Registers a menu to be scrolled, with the specified scrolling interval,
206     * and the specified numbers of items fixed at the top of the menu.
207     *
208     * @param menu the menu
209     * @param interval the scroll interval, in milliseconds
210     * @param topFixedCount the number of items to fix at the top.  May be 0.
211     * @throws IllegalArgumentException if scrollCount or interval is 0 or
212     * negative or if topFixedCount is negative
213     * @return the MenuScroller
214     */
215    public static MenuScroller setScrollerFor(JMenu menu, int interval, int topFixedCount) {
216        return new MenuScroller(menu, interval, topFixedCount);
217    }
218
219    /**
220     * Registers a popup menu to be scrolled, with the specified scrolling interval,
221     * and the specified numbers of items fixed at the top of the popup menu.
222     *
223     * @param menu the popup menu
224     * @param interval the scroll interval, in milliseconds
225     * @param topFixedCount the number of items to fix at the top. May be 0
226     * @throws IllegalArgumentException if scrollCount or interval is 0 or
227     * negative or if topFixedCount is negative
228     * @return the MenuScroller
229     */
230    public static MenuScroller setScrollerFor(JPopupMenu menu, int interval, int topFixedCount) {
231        return new MenuScroller(menu, interval, topFixedCount);
232    }
233
234    /**
235     * Constructs a {@code MenuScroller} that scrolls a menu with the
236     * default scrolling interval.
237     *
238     * @param menu the menu
239     * @throws IllegalArgumentException if scrollCount is 0 or negative
240     */
241    public MenuScroller(JMenu menu) {
242        this(menu, 150);
243    }
244
245    public MenuScroller(JComponent parentComp, JMenu menu) {
246        this(menu, 150);
247        parent = parentComp;
248    }
249
250    /**
251     * Constructs a {@code MenuScroller} that scrolls a popup menu with the
252     * default scrolling interval.
253     *
254     * @param menu the popup menu
255     * @throws IllegalArgumentException if scrollCount is 0 or negative
256     */
257    public MenuScroller(JPopupMenu menu) {
258        this(menu, 150);
259    }
260
261    /**
262     * Constructs a {@code MenuScroller} that scrolls a menu with the
263     * specified scrolling interval.
264     *
265     * @param menu the menu
266     * @param interval the scroll interval, in milliseconds
267     * @throws IllegalArgumentException if scrollCount or interval is 0 or negative
268     */
269    public MenuScroller(JMenu menu, int interval) {
270        this(menu, interval, 0);
271    }
272
273    /**
274     * Constructs a {@code MenuScroller} that scrolls a popup menu with the
275     * specified scrolling interval.
276     *
277     * @param menu the popup menu
278     * @param interval the scroll interval, in milliseconds
279     * @throws IllegalArgumentException if scrollCount or interval is 0 or negative
280     */
281    public MenuScroller(JPopupMenu menu, int interval) {
282        this(menu, interval, 0);
283    }
284
285    public MenuScroller(JComponent parentComp, JMenu menu, int interval) {
286        this(menu, interval, 0);
287        parent = parentComp;
288    }
289
290    /**
291     * Constructs a {@code MenuScroller} that scrolls a menu with the
292     * specified scrolling interval, and the specified numbers of items fixed at
293     * the top of the menu.
294     *
295     * @param menu the menu
296     * @param interval the scroll interval, in milliseconds
297     * @param topFixedCount the number of items to fix at the top.  May be 0
298     * @throws IllegalArgumentException if scrollCount or interval is 0 or
299     * negative or if topFixedCount is negative
300     */
301    public MenuScroller(JMenu menu, int interval, int topFixedCount) {
302        this(menu.getPopupMenu(), interval, topFixedCount);
303    }
304
305    public MenuScroller(JComponent parentComp, JMenu menu, int interval, int topFixedCount) {
306        this(menu.getPopupMenu(), interval, topFixedCount);
307        parent = parentComp;
308    }
309
310    /**
311     * Constructs a {@code MenuScroller} that scrolls a popup menu with the
312     * specified scrolling interval, and the specified numbers of items fixed at
313     * the top of the popup menu.
314     *
315     * @param menu the popup menu
316     * @param interval the scroll interval, in milliseconds
317     * @param topFixedCount the number of items to fix at the top.  May be 0
318     * @throws IllegalArgumentException if scrollCount or interval is 0 or
319     * negative or if topFixedCount is negative
320     */
321    public MenuScroller(JPopupMenu menu, int interval, int topFixedCount) {
322        if (interval <= 0) {
323            throw new IllegalArgumentException("interval must be greater than 0");
324        }
325        if (topFixedCount < 0) {
326            throw new IllegalArgumentException("topFixedCount cannot be negative");
327        }
328
329        upItem = new MenuScrollItem(MenuIcon.UP, -1);
330        downItem = new MenuScrollItem(MenuIcon.DOWN, +1);
331        setInterval(interval);
332        setTopFixedCount(topFixedCount);
333
334        this.menu = menu;
335        menu.addPopupMenuListener(menuListener);
336        menu.addMouseWheelListener(mouseWheelListener);
337    }
338
339    /**
340     * Returns the scroll interval in milliseconds
341     *
342     * @return the scroll interval in milliseconds
343     */
344    public int getInterval() {
345        return interval;
346    }
347
348    /**
349     * Sets the scroll interval in milliseconds
350     *
351     * @param interval the scroll interval in milliseconds
352     * @throws IllegalArgumentException if interval is 0 or negative
353     */
354    public void setInterval(int interval) {
355        if (interval <= 0) {
356            throw new IllegalArgumentException("interval must be greater than 0");
357        }
358        upItem.setInterval(interval);
359        downItem.setInterval(interval);
360        this.interval = interval;
361    }
362
363    /**
364     * Returns the number of items fixed at the top of the menu or popup menu.
365     *
366     * @return the number of items
367     */
368    public int getTopFixedCount() {
369        return topFixedCount;
370    }
371
372    /**
373     * Sets the number of items to fix at the top of the menu or popup menu.
374     *
375     * @param topFixedCount the number of items
376     */
377    public void setTopFixedCount(int topFixedCount) {
378        if (firstIndex <= topFixedCount) {
379            firstIndex = topFixedCount;
380        } else {
381            firstIndex += (topFixedCount - this.topFixedCount);
382        }
383        this.topFixedCount = topFixedCount;
384    }
385
386    /**
387     * Removes this MenuScroller from the associated menu and restores the
388     * default behavior of the menu.
389     */
390    public void dispose() {
391        if (menu != null) {
392            menu.removePopupMenuListener(menuListener);
393            menu.removeMouseWheelListener(mouseWheelListener);
394            menu.setPreferredSize(null);
395            menu = null;
396        }
397    }
398
399    public void resetMenu() {
400        menuItems = menu.getComponents();
401        refreshMenu();
402    }
403
404    /**
405     * Ensures that the {@code dispose} method of this MenuScroller is
406     * called when there are no more refrences to it.
407     *
408     * @exception  Throwable if an error occurs.
409     * @see MenuScroller#dispose()
410     */
411    @Override protected void finalize() throws Throwable {
412        dispose();
413        super.finalize();
414    }
415
416    public void setParent(JComponent parent) {
417        this.parent = parent;
418    }
419
420    private void refreshMenu() {
421        if ((menuItems != null) && (menuItems.length > 0)) {
422
423            int allItemsHeight = 0;
424            for (Component item : menuItems) {
425                allItemsHeight += item.getPreferredSize().height;
426            }
427
428            int allowedHeight = getMaxDimensionOnScreen(parent, menu).height - parent.getInsets().top;
429
430            boolean mustScroll = allItemsHeight > allowedHeight;
431
432            if (mustScroll) {
433                firstIndex = Math.max(topFixedCount, firstIndex);
434                int scrollCount = computeScrollCount(firstIndex);
435                firstIndex = Math.min(menuItems.length - scrollCount, firstIndex);
436
437                upItem.setEnabled(firstIndex > topFixedCount);
438                downItem.setEnabled((firstIndex + scrollCount) < menuItems.length);
439
440                menu.removeAll();
441                for (int i = 0; i < topFixedCount; i++) {
442                    menu.add(menuItems[i]);
443                }
444                if (topFixedCount > 0) {
445                    menu.addSeparator();
446                }
447
448                menu.add(upItem);
449                for (int i = firstIndex; i < (scrollCount + firstIndex); i++) {
450                    menu.add(menuItems[i]);
451                }
452                menu.add(downItem);
453
454                int preferredWidth = 0;
455                for (Component item : menuItems) {
456                    preferredWidth = Math.max(preferredWidth, item.getPreferredSize().width);
457                }
458                menu.setPreferredSize(new Dimension(preferredWidth, menu.getPreferredSize().height));
459
460            } else if (!Arrays.equals(menu.getComponents(), menuItems)) {
461                // Scroll is not needed but menu is not up to date
462                menu.removeAll();
463                for (Component item : menuItems) {
464                    menu.add(item);
465                }
466            }
467
468            menu.revalidate();
469            menu.repaint();
470        }
471    }
472
473    private class MenuScrollListener implements PopupMenuListener {
474
475        @Override public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
476            setMenuItems();
477        }
478
479        @Override public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
480//            restoreMenuItems();
481            setMenuItems();
482        }
483
484
485        @Override public void popupMenuCanceled(PopupMenuEvent e) {
486//            restoreMenuItems();
487            setMenuItems();
488        }
489
490        private void setMenuItems() {
491            menuItems = menu.getComponents();
492            refreshMenu();
493        }
494
495        private void restoreMenuItems() {
496            menu.removeAll();
497            for (Component component : menuItems) {
498                menu.add(component);
499            }
500        }
501    }
502
503    private class MenuScrollTimer extends Timer {
504        public MenuScrollTimer(final int increment, int interval) {
505            // software developers hate this one weird trick of a compiler bug
506            // workaround:
507            // http://bugs.java.com/bugdatabase/view_bug.do?bug_id=8006684
508            //
509            // super(interval, e -> {
510            //     firstIndex += increment;
511            //     refreshMenu();
512            // });
513            super(interval, new TimerListener(increment));
514        }
515    }
516
517    // see the comment in the MenuScrollTimer constructor
518    private class TimerListener implements ActionListener {
519        private int increment;
520
521        public TimerListener(int increment) {
522            this.increment = increment;
523        }
524
525        @Override public void actionPerformed(ActionEvent e) {
526            firstIndex += increment;
527            refreshMenu();
528        }
529    }
530
531    private class MenuScrollItem extends JMenuItem
532        implements ChangeListener {
533
534        private MenuScrollTimer timer;
535
536        public MenuScrollItem(MenuIcon icon, int increment) {
537            setIcon(icon);
538            setDisabledIcon(icon);
539            timer = new MenuScrollTimer(increment, interval);
540            addChangeListener(this);
541        }
542
543        public void setInterval(int interval) {
544            timer.setDelay(interval);
545        }
546
547        @Override
548        public void stateChanged(ChangeEvent e) {
549            if (isArmed() && !timer.isRunning()) {
550                timer.start();
551            }
552            if (!isArmed() && timer.isRunning()) {
553                timer.stop();
554            }
555        }
556    }
557
558    private static enum MenuIcon implements Icon {
559
560        UP(9, 1, 9),
561        DOWN(1, 9, 1);
562        static final int[] XPOINTS = {1, 5, 9};
563        final int[] yPoints;
564
565        MenuIcon(int... yPoints) {
566            this.yPoints = yPoints;
567        }
568
569        @Override public void paintIcon(Component c, Graphics g, int x, int y) {
570            Dimension size = c.getSize();
571            Graphics g2 = g.create((size.width / 2) - 5, (size.height / 2) - 5, 10, 10);
572            g2.setColor(Color.GRAY);
573            g2.drawPolygon(XPOINTS, yPoints, 3);
574            if (c.isEnabled()) {
575                g2.setColor(Color.BLACK);
576                g2.fillPolygon(XPOINTS, yPoints, 3);
577            }
578            g2.dispose();
579        }
580
581        @Override public int getIconWidth() {
582            return 0;
583        }
584
585        @Override public int getIconHeight() {
586            return ARROW_ICON_HEIGHT;
587        }
588    }
589
590    private class MouseScrollListener implements MouseWheelListener {
591        @Override public void mouseWheelMoved(MouseWheelEvent mwe) {
592            firstIndex += mwe.getWheelRotation();
593            refreshMenu();
594            mwe.consume();
595        }
596    }
597}