001/*
002 * This file is part of McIDAS-V
003 *
004 * Copyright 2007-2015
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 org.slf4j.Logger;
032import org.slf4j.LoggerFactory;
033
034import java.awt.Color;
035import java.awt.Component;
036import java.awt.Dimension;
037import java.awt.Graphics;
038
039import java.awt.Point;
040import java.awt.Toolkit;
041import java.awt.event.ActionEvent;
042import java.awt.event.ActionListener;
043
044import javax.swing.Icon;
045import javax.swing.JComponent;
046import javax.swing.JMenu;
047import javax.swing.JMenuItem;
048import javax.swing.JPopupMenu;
049import javax.swing.MenuSelectionManager;
050import javax.swing.SwingUtilities;
051import javax.swing.Timer;
052
053import javax.swing.event.ChangeEvent;
054import javax.swing.event.ChangeListener;
055import javax.swing.event.PopupMenuEvent;
056import javax.swing.event.PopupMenuListener;
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 and/or
061 * bottom of the menu.
062 * <p>
063 * <b>Implementation note:</b>  The default number of items to display
064 * at a time is 15, and the default scrolling interval is 125 milliseconds.
065 * <p>
066 * This class is the work of Darryl Burke and the commenters at
067 * <a href=http://tips4java.wordpress.com/2009/02/01/menu-scroller/>this link</a>.
068 *
069 * @version 1.5.0 04/05/12
070 * @author Darryl
071 */
072public class MenuScroller {
073
074    private static final Logger logger = LoggerFactory.getLogger(MenuScroller.class);
075
076    //private JMenu menu;
077    private JPopupMenu menu;
078    private Component[] menuItems;
079    private MenuScrollItem upItem;
080    private MenuScrollItem downItem;
081    private final MenuScrollListener menuListener = new MenuScrollListener();
082    private int scrollCount;
083    private int interval;
084    private int topFixedCount;
085    private int bottomFixedCount;
086    private int firstIndex = 0;
087    private int keepVisibleIndex = -1;
088
089    /**
090     * Calculates the number for scrollCount such that the menu fills the available
091     * vertical space from the point (mouse press) to the bottom of the screen.
092     *
093     * @param c {@code Component} on which the point parameter is based.
094     * @param pt {@code Point} at which the top of the menu will appear (in component coordinate space).
095     * @param item  {@code JMenuItem} of prototypical height off of which the average height is determined.
096     * @param bottomFixedCount Needed to offset the returned scrollCount.
097     *
098     * @return the {@literal "scrollCount"} for the given parameters.
099     */
100    public static int scrollCountForScreen(Component c, Point pt, JMenuItem item, int bottomFixedCount) {
101        Dimension screenSize = Toolkit.getDefaultToolkit().getScreenSize();
102        Point ptScreen = new Point(pt);
103        SwingUtilities.convertPointToScreen(ptScreen, c);
104        int height = screenSize.height - ptScreen.y;
105        int miHeight = item.getPreferredSize().height;
106        // 2 just takes the menu up a bit from the bottom which looks nicer
107        return (height / miHeight) - bottomFixedCount - 2;
108    }
109
110    /**
111     * Registers a menu to be scrolled with the default number of items to
112     * display at a time and the default scrolling interval.
113     *
114     * @param menu the menu
115     * @return the MenuScroller
116     */
117    public static MenuScroller setScrollerFor(JMenu menu) {
118        return new MenuScroller(menu);
119    }
120
121    /**
122     * Registers a popup menu to be scrolled with the default number of items to
123     * display at a time and the default scrolling interval.
124     *
125     * @param menu the popup menu
126     * @return the MenuScroller
127     */
128    public static MenuScroller setScrollerFor(JPopupMenu menu) {
129        return new MenuScroller(menu);
130    }
131
132    /**
133     * Registers a menu to be scrolled with the default number of items to
134     * display at a time and the specified scrolling interval.
135     *
136     * @param menu the menu
137     * @param scrollCount the number of items to display at a time
138     * @return the MenuScroller
139     * @throws IllegalArgumentException if scrollCount is 0 or negative
140     */
141    public static MenuScroller setScrollerFor(JMenu menu, int scrollCount) {
142        return new MenuScroller(menu, scrollCount);
143    }
144
145    /**
146     * Registers a popup menu to be scrolled with the default number of items to
147     * display at a time and the specified scrolling interval.
148     *
149     * @param menu the popup menu
150     * @param scrollCount the number of items to display at a time
151     * @return the MenuScroller
152     * @throws IllegalArgumentException if scrollCount is 0 or negative
153     */
154    public static MenuScroller setScrollerFor(JPopupMenu menu, int scrollCount) {
155        return new MenuScroller(menu, scrollCount);
156    }
157
158    /**
159     * Registers a menu to be scrolled, with the specified number of items to
160     * display at a time and the specified scrolling interval.
161     *
162     * @param menu the menu
163     * @param scrollCount the number of items to be displayed at a time
164     * @param interval the scroll interval, in milliseconds
165     * @return the MenuScroller
166     * @throws IllegalArgumentException if scrollCount or interval is 0 or negative
167     */
168    public static MenuScroller setScrollerFor(JMenu menu, int scrollCount, int interval) {
169        return new MenuScroller(menu, scrollCount, interval);
170    }
171
172    /**
173     * Registers a popup menu to be scrolled, with the specified number of items to
174     * display at a time and the specified scrolling interval.
175     *
176     * @param menu the popup menu
177     * @param scrollCount the number of items to be displayed at a time
178     * @param interval the scroll interval, in milliseconds
179     * @return the MenuScroller
180     * @throws IllegalArgumentException if scrollCount or interval is 0 or negative
181     */
182    public static MenuScroller setScrollerFor(JPopupMenu menu, int scrollCount, int interval) {
183        return new MenuScroller(menu, scrollCount, interval);
184    }
185
186    /**
187     * Registers a menu to be scrolled, with the specified number of items
188     * to display in the scrolling region, the specified scrolling interval,
189     * and the specified numbers of items fixed at the top and bottom of the
190     * menu.
191     *
192     * @param menu the menu
193     * @param scrollCount the number of items to display in the scrolling portion
194     * @param interval the scroll interval, in milliseconds
195     * @param topFixedCount the number of items to fix at the top.  May be 0.
196     * @param bottomFixedCount the number of items to fix at the bottom. May be 0
197     * @throws IllegalArgumentException if scrollCount or interval is 0 or
198     * negative or if topFixedCount or bottomFixedCount is negative
199     * @return the MenuScroller
200     */
201    public static MenuScroller setScrollerFor(JMenu menu, int scrollCount, int interval,
202                                              int topFixedCount, int bottomFixedCount) {
203        return new MenuScroller(menu, scrollCount, interval,
204            topFixedCount, bottomFixedCount);
205    }
206
207    /**
208     * Registers a popup menu to be scrolled, with the specified number of items
209     * to display in the scrolling region, the specified scrolling interval,
210     * and the specified numbers of items fixed at the top and bottom of the
211     * popup menu.
212     *
213     * @param menu the popup menu
214     * @param scrollCount the number of items to display in the scrolling portion
215     * @param interval the scroll interval, in milliseconds
216     * @param topFixedCount the number of items to fix at the top.  May be 0
217     * @param bottomFixedCount the number of items to fix at the bottom.  May be 0
218     * @throws IllegalArgumentException if scrollCount or interval is 0 or
219     * negative or if topFixedCount or bottomFixedCount is negative
220     * @return the MenuScroller
221     */
222    public static MenuScroller setScrollerFor(JPopupMenu menu, int scrollCount, int interval,
223                                              int topFixedCount, int bottomFixedCount) {
224        return new MenuScroller(menu, scrollCount, interval,
225            topFixedCount, bottomFixedCount);
226    }
227
228    /**
229     * Constructs a {@code MenuScroller} that scrolls a menu with the
230     * default number of items to display at a time, and default scrolling
231     * interval.
232     *
233     * @param menu the menu
234     */
235    public MenuScroller(JMenu menu) {
236        this(menu, 15);
237    }
238
239    /**
240     * Constructs a {@code MenuScroller} that scrolls a popup menu with the
241     * default number of items to display at a time, and default scrolling
242     * interval.
243     *
244     * @param menu the popup menu
245     */
246    public MenuScroller(JPopupMenu menu) {
247        this(menu, 15);
248    }
249
250    /**
251     * Constructs a {@code MenuScroller} that scrolls a menu with the
252     * specified number of items to display at a time, and default scrolling
253     * interval.
254     *
255     * @param menu the menu
256     * @param scrollCount the number of items to display at a time
257     * @throws IllegalArgumentException if scrollCount is 0 or negative
258     */
259    public MenuScroller(JMenu menu, int scrollCount) {
260        this(menu, scrollCount, 150);
261    }
262
263    /**
264     * Constructs a {@code MenuScroller} that scrolls a popup menu with the
265     * specified number of items to display at a time, and default scrolling
266     * interval.
267     *
268     * @param menu the popup menu
269     * @param scrollCount the number of items to display at a time
270     * @throws IllegalArgumentException if scrollCount is 0 or negative
271     */
272    public MenuScroller(JPopupMenu menu, int scrollCount) {
273        this(menu, scrollCount, 150);
274    }
275
276    /**
277     * Constructs a {@code MenuScroller} that scrolls a menu with the
278     * specified number of items to display at a time, and specified scrolling
279     * interval.
280     *
281     * @param menu the menu
282     * @param scrollCount the number of items to display at a time
283     * @param interval the scroll interval, in milliseconds
284     * @throws IllegalArgumentException if scrollCount or interval is 0 or negative
285     */
286    public MenuScroller(JMenu menu, int scrollCount, int interval) {
287        this(menu, scrollCount, interval, 0, 0);
288    }
289
290    /**
291     * Constructs a {@code MenuScroller} that scrolls a popup menu with the
292     * specified number of items to display at a time, and specified scrolling
293     * interval.
294     *
295     * @param menu the popup menu
296     * @param scrollCount the number of items to display at a time
297     * @param interval the scroll interval, in milliseconds
298     * @throws IllegalArgumentException if scrollCount or interval is 0 or negative
299     */
300    public MenuScroller(JPopupMenu menu, int scrollCount, int interval) {
301        this(menu, scrollCount, interval, 0, 0);
302    }
303
304    /**
305     * Constructs a {@code MenuScroller} that scrolls a menu with the
306     * specified number of items to display in the scrolling region, the
307     * specified scrolling interval, and the specified numbers of items fixed at
308     * the top and bottom of the menu.
309     *
310     * @param menu the menu
311     * @param scrollCount the number of items to display in the scrolling portion
312     * @param interval the scroll interval, in milliseconds
313     * @param topFixedCount the number of items to fix at the top.  May be 0
314     * @param bottomFixedCount the number of items to fix at the bottom.  May be 0
315     * @throws IllegalArgumentException if scrollCount or interval is 0 or
316     * negative or if topFixedCount or bottomFixedCount is negative
317     */
318    public MenuScroller(JMenu menu, int scrollCount, int interval,
319                        int topFixedCount, int bottomFixedCount) {
320        this(menu.getPopupMenu(), scrollCount, interval, topFixedCount, bottomFixedCount);
321    }
322
323    /**
324     * Constructs a {@code MenuScroller} that scrolls a popup menu with the
325     * specified number of items to display in the scrolling region, the
326     * specified scrolling interval, and the specified numbers of items fixed at
327     * the top and bottom of the popup menu.
328     *
329     * @param menu the popup menu
330     * @param scrollCount the number of items to display in the scrolling portion
331     * @param interval the scroll interval, in milliseconds
332     * @param topFixedCount the number of items to fix at the top.  May be 0
333     * @param bottomFixedCount the number of items to fix at the bottom.  May be 0
334     * @throws IllegalArgumentException if scrollCount or interval is 0 or
335     * negative or if topFixedCount or bottomFixedCount is negative
336     */
337    public MenuScroller(JPopupMenu menu, int scrollCount, int interval,
338                        int topFixedCount, int bottomFixedCount) {
339        if (scrollCount <= 0 || interval <= 0) {
340            throw new IllegalArgumentException("scrollCount and interval must be greater than 0");
341        }
342        if (topFixedCount < 0 || bottomFixedCount < 0) {
343            throw new IllegalArgumentException("topFixedCount and bottomFixedCount cannot be negative");
344        }
345
346        upItem = new MenuScrollItem(MenuIcon.UP, -1);
347        downItem = new MenuScrollItem(MenuIcon.DOWN, +1);
348        setScrollCount(scrollCount);
349        setInterval(interval);
350        setTopFixedCount(topFixedCount);
351        setBottomFixedCount(bottomFixedCount);
352
353        this.menu = menu;
354        menu.addPopupMenuListener(menuListener);
355    }
356
357    /**
358     * Returns the scroll interval in milliseconds
359     *
360     * @return the scroll interval in milliseconds
361     */
362    public int getInterval() {
363        return interval;
364    }
365
366    /**
367     * Sets the scroll interval in milliseconds
368     *
369     * @param interval the scroll interval in milliseconds
370     * @throws IllegalArgumentException if interval is 0 or negative
371     */
372    public void setInterval(int interval) {
373        if (interval <= 0) {
374            throw new IllegalArgumentException("interval must be greater than 0");
375        }
376        upItem.setInterval(interval);
377        downItem.setInterval(interval);
378        this.interval = interval;
379    }
380
381    /**
382     * Returns the number of items in the scrolling portion of the menu.
383     *
384     * @return the number of items to display at a time
385     */
386    public int getScrollCount() {
387        return scrollCount;
388    }
389
390    /**
391     * Sets the number of items in the scrolling portion of the menu.
392     *
393     * @param scrollCount the number of items to display at a time
394     * @throws IllegalArgumentException if scrollCount is 0 or negative
395     */
396    public void setScrollCount(int scrollCount) {
397        if (scrollCount <= 0) {
398            throw new IllegalArgumentException("scrollCount must be greater than 0");
399        }
400        this.scrollCount = scrollCount;
401        MenuSelectionManager.defaultManager().clearSelectedPath();
402    }
403
404    /**
405     * Returns the number of items fixed at the top of the menu or popup menu.
406     *
407     * @return the number of items
408     */
409    public int getTopFixedCount() {
410        return topFixedCount;
411    }
412
413    /**
414     * Sets the number of items to fix at the top of the menu or popup menu.
415     *
416     * @param topFixedCount the number of items
417     */
418    public void setTopFixedCount(int topFixedCount) {
419        if (firstIndex <= topFixedCount) {
420            firstIndex = topFixedCount;
421        } else {
422            firstIndex += (topFixedCount - this.topFixedCount);
423        }
424        this.topFixedCount = topFixedCount;
425    }
426
427    /**
428     * Returns the number of items fixed at the bottom of the menu or popup menu.
429     *
430     * @return the number of items
431     */
432    public int getBottomFixedCount() {
433        return bottomFixedCount;
434    }
435
436    /**
437     * Sets the number of items to fix at the bottom of the menu or popup menu.
438     *
439     * @param bottomFixedCount the number of items
440     */
441    public void setBottomFixedCount(int bottomFixedCount) {
442        this.bottomFixedCount = bottomFixedCount;
443    }
444
445    /**
446     * Scrolls the specified item into view each time the menu is opened.  Call this method with
447     * {@code null} to restore the default behavior, which is to show the menu as it last
448     * appeared.
449     *
450     * @param item the item to keep visible
451     * @see #keepVisible(int)
452     */
453    public void keepVisible(JMenuItem item) {
454        if (item == null) {
455            keepVisibleIndex = -1;
456        } else {
457            int index = menu.getComponentIndex(item);
458            keepVisibleIndex = index;
459        }
460    }
461
462    /**
463     * Scrolls the item at the specified index into view each time the menu is opened.  Call this
464     * method with {@code -1} to restore the default behavior, which is to show the menu as
465     * it last appeared.
466     *
467     * @param index the index of the item to keep visible
468     * @see #keepVisible(javax.swing.JMenuItem)
469     */
470    public void keepVisible(int index) {
471        keepVisibleIndex = index;
472    }
473
474    /**
475     * Removes this MenuScroller from the associated menu and restores the
476     * default behavior of the menu.
477     */
478    public void dispose() {
479        if (menu != null) {
480            menu.removePopupMenuListener(menuListener);
481            menu = null;
482        }
483    }
484
485    /**
486     * Ensures that the {@code dispose} method of this MenuScroller is
487     * called when there are no more refrences to it.
488     *
489     * @exception  Throwable if an error occurs.
490     * @see MenuScroller#dispose()
491     */
492    @Override public void finalize() throws Throwable {
493        dispose();
494    }
495
496    private void refreshMenu() {
497        if (menuItems != null && menuItems.length > 0) {
498            firstIndex = Math.max(topFixedCount, firstIndex);
499            firstIndex = Math.min(menuItems.length - bottomFixedCount - scrollCount, firstIndex);
500
501            upItem.setEnabled(firstIndex > topFixedCount);
502            downItem.setEnabled(firstIndex + scrollCount < menuItems.length - bottomFixedCount);
503
504            menu.removeAll();
505            for (int i = 0; i < topFixedCount; i++) {
506                menu.add(menuItems[i]);
507            }
508            if (topFixedCount > 0) {
509                menu.addSeparator();
510            }
511
512            menu.add(upItem);
513            for (int i = firstIndex; i < scrollCount + firstIndex; i++) {
514                menu.add(menuItems[i]);
515            }
516            menu.add(downItem);
517
518            if (bottomFixedCount > 0) {
519                menu.addSeparator();
520            }
521            for (int i = menuItems.length - bottomFixedCount; i < menuItems.length; i++) {
522                menu.add(menuItems[i]);
523            }
524
525            int preferredWidth = 0;
526            for (Component item : menuItems) {
527                preferredWidth = Math.max(preferredWidth, item.getPreferredSize().width);
528            }
529            menu.setPreferredSize(new Dimension(preferredWidth, menu.getPreferredSize().height));
530
531            JComponent parent = (JComponent)upItem.getParent();
532            parent.revalidate();
533            parent.repaint();
534            Component invoker = menu.getInvoker();
535            Dimension invokerSize = invoker.getSize();
536            Point invokerLocation = invoker.getLocationOnScreen();
537            int menuX = (int)(invokerSize.getWidth() + invokerLocation.getX());
538            Point newMenuLocation = new Point(menuX, 1000);
539            if (!menu.isVisible()) {
540                menu.setLocation(newMenuLocation);
541            }
542        }
543    }
544
545    private class MenuScrollListener implements PopupMenuListener {
546
547        @Override public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
548            setMenuItems();
549//            logger.trace("e={}", e);
550        }
551
552        @Override public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
553            restoreMenuItems();
554        }
555
556        @Override public void popupMenuCanceled(PopupMenuEvent e) {
557            restoreMenuItems();
558        }
559
560        private void setMenuItems() {
561            menuItems = menu.getComponents();
562            if (keepVisibleIndex >= topFixedCount
563                && keepVisibleIndex <= menuItems.length - bottomFixedCount
564                && (keepVisibleIndex > firstIndex + scrollCount
565                || keepVisibleIndex < firstIndex)) {
566                firstIndex = Math.min(firstIndex, keepVisibleIndex);
567                firstIndex = Math.max(firstIndex, keepVisibleIndex - scrollCount + 1);
568            }
569            if (menuItems.length > topFixedCount + scrollCount + bottomFixedCount) {
570                refreshMenu();
571            }
572        }
573
574        private void restoreMenuItems() {
575            menu.removeAll();
576            for (Component component : menuItems) {
577                menu.add(component);
578            }
579        }
580    }
581
582    private class MenuScrollTimer extends Timer {
583
584        public MenuScrollTimer(final int increment, int interval) {
585            super(interval, new ActionListener() {
586
587                @Override public void actionPerformed(ActionEvent e) {
588                    firstIndex += increment;
589                    refreshMenu();
590                }
591            });
592        }
593    }
594
595    private class MenuScrollItem extends JMenuItem
596        implements ChangeListener {
597
598        private MenuScrollTimer timer;
599
600        public MenuScrollItem(MenuIcon icon, int increment) {
601            setIcon(icon);
602            setDisabledIcon(icon);
603            timer = new MenuScrollTimer(increment, interval);
604            addChangeListener(this);
605        }
606
607        public void setInterval(int interval) {
608            timer.setDelay(interval);
609        }
610
611        @Override public void stateChanged(ChangeEvent e) {
612            if (isArmed() && !timer.isRunning()) {
613                timer.start();
614            }
615            if (!isArmed() && timer.isRunning()) {
616                timer.stop();
617            }
618        }
619    }
620
621    private static enum MenuIcon implements Icon {
622
623        UP(9, 1, 9),
624        DOWN(1, 9, 1);
625        final int[] xPoints = {1, 5, 9};
626        final int[] yPoints;
627
628        MenuIcon(int... yPoints) {
629            this.yPoints = yPoints;
630        }
631
632        @Override public void paintIcon(Component c, Graphics g, int x, int y) {
633            Dimension size = c.getSize();
634            Graphics g2 = g.create(size.width / 2 - 5, size.height / 2 - 5, 10, 10);
635            g2.setColor(Color.GRAY);
636            g2.drawPolygon(xPoints, yPoints, 3);
637            if (c.isEnabled()) {
638                g2.setColor(Color.BLACK);
639                g2.fillPolygon(xPoints, yPoints, 3);
640            }
641            g2.dispose();
642        }
643
644        @Override public int getIconWidth() {
645            return 0;
646        }
647
648        @Override public int getIconHeight() {
649            return 10;
650        }
651    }
652}