001package jmri.util;
002
003import java.awt.Color;
004import java.awt.Component;
005import java.awt.Dimension;
006import java.awt.Graphics;
007import java.awt.event.ActionEvent;
008import java.awt.event.ActionListener;
009import java.awt.event.KeyEvent;
010import java.awt.event.MouseWheelEvent;
011import java.awt.event.MouseWheelListener;
012import javax.swing.Icon;
013import javax.swing.JComponent;
014import javax.swing.JMenu;
015import javax.swing.JMenuItem;
016import javax.swing.JPopupMenu;
017import javax.swing.MenuSelectionManager;
018import javax.swing.Timer;
019import javax.swing.event.ChangeEvent;
020import javax.swing.event.ChangeListener;
021import javax.swing.event.MenuKeyEvent;
022import javax.swing.event.MenuKeyListener;
023import javax.swing.event.PopupMenuEvent;
024import javax.swing.event.PopupMenuListener;
025import org.slf4j.Logger;
026import org.slf4j.LoggerFactory;
027
028/**
029 * A class that provides scrolling capabilities to a long menu dropdown or popup
030 * menu. A number of items can optionally be frozen at the top and/or bottom of
031 * the menu.
032 * <p>
033 * <b>Implementation note:</b> The default number of items to display at a time
034 * is 15, and the default scrolling interval is 125 milliseconds.
035 *
036 * @version 1.5.0 04/05/12
037 * @author Darryl
038 * @version 1.5.1 07/20/17 - added scrollwheel support
039 * @author George Warner
040 */
041// NOTE: Provided by Darryl Burke from <https://tips4java.wordpress.com/2009/02/01/menu-scroller/>
042// (Thank you DarrylB! ;-)
043public final class MenuScroller
044        implements MouseWheelListener {
045
046    //private JMenu menu;
047    private final JPopupMenu menu;
048    private Component[] menuItems;
049    private MenuScrollerItem upItem;
050    private MenuScrollerItem downItem;
051    private final MenuScrollerPopupMenuListener menuScrollerPopupMenuListener = new MenuScrollerPopupMenuListener();
052    private final MenuScrollerMenuKeyListener menuScrollerMenuKeyListener = new MenuScrollerMenuKeyListener();
053    private int scrollCount;
054    private int interval;
055    private int topFixedCount;
056    private int bottomFixedCount;
057    private int firstIndex = 0;
058    private int keepVisibleIndex = -1;
059
060    /**
061     * Register a menu to be scrolled with the default number of items to
062     * display at a time and the default scrolling interval.
063     *
064     * @param menu the menu
065     * @return the MenuScroller
066     */
067    public static MenuScroller setScrollerFor(JMenu menu) {
068        return new MenuScroller(menu);
069    }
070
071    /**
072     * Register a popup menu to be scrolled with the default number of items to
073     * display at a time and the default scrolling interval.
074     *
075     * @param menu the popup menu
076     * @return the MenuScroller
077     */
078    public static MenuScroller setScrollerFor(JPopupMenu menu) {
079        return new MenuScroller(menu);
080    }
081
082    /**
083     * Register a menu to be scrolled with the default number of items to
084     * display at a time and the specified scrolling interval.
085     *
086     * @param menu        the menu
087     * @param scrollCount the number of items to display at a time
088     * @return the MenuScroller
089     * @throws IllegalArgumentException if scrollCount is 0 or negative
090     */
091    public static MenuScroller setScrollerFor(JMenu menu, int scrollCount) {
092        return new MenuScroller(menu, scrollCount);
093    }
094
095    /**
096     * Register a popup menu to be scrolled with the default number of items to
097     * display at a time and the specified scrolling interval.
098     *
099     * @param menu        the popup menu
100     * @param scrollCount the number of items to display at a time
101     * @return the MenuScroller
102     * @throws IllegalArgumentException if scrollCount is 0 or negative
103     */
104    public static MenuScroller setScrollerFor(JPopupMenu menu, int scrollCount) {
105        return new MenuScroller(menu, scrollCount);
106    }
107
108    /**
109     * Register a menu to be scrolled, with the specified number of items to
110     * display at a time and the specified scrolling interval.
111     *
112     * @param menu        the menu
113     * @param scrollCount the number of items to be displayed at a time
114     * @param interval    the scroll interval, in milliseconds
115     * @return the MenuScroller
116     * @throws IllegalArgumentException if scrollCount or interval is 0 or
117     *                                  negative
118     */
119    public static MenuScroller setScrollerFor(JMenu menu, int scrollCount, int interval) {
120        return new MenuScroller(menu, scrollCount, interval);
121    }
122
123    /**
124     * Register a popup menu to be scrolled, with the specified number of items
125     * to display at a time and the specified scrolling interval.
126     *
127     * @param menu        the popup menu
128     * @param scrollCount the number of items to be displayed at a time
129     * @param interval    the scroll interval, in milliseconds
130     * @return the MenuScroller
131     * @throws IllegalArgumentException if scrollCount or interval is 0 or
132     *                                  negative
133     */
134    public static MenuScroller setScrollerFor(JPopupMenu menu, int scrollCount, int interval) {
135        return new MenuScroller(menu, scrollCount, interval);
136    }
137
138    /**
139     * Register a menu to be scrolled, with the specified number of items to
140     * display in the scrolling region, the specified scrolling interval, and
141     * the specified numbers of items fixed at the top and bottom of the menu.
142     *
143     * @param menu             the menu
144     * @param scrollCount      the number of items to display in the scrolling
145     *                         portion
146     * @param interval         the scroll interval, in milliseconds
147     * @param topFixedCount    the number of items to fix at the top. May be 0.
148     * @param bottomFixedCount the number of items to fix at the bottom. May be
149     *                         0
150     * @throws IllegalArgumentException if scrollCount or interval is 0 or
151     *                                  negative or if topFixedCount or
152     *                                  bottomFixedCount is negative
153     * @return the MenuScroller
154     */
155    public static MenuScroller setScrollerFor(JMenu menu, int scrollCount, int interval,
156            int topFixedCount, int bottomFixedCount) {
157        return new MenuScroller(menu, scrollCount, interval,
158                topFixedCount, bottomFixedCount);
159    }
160
161    /**
162     * Register a popup menu to be scrolled, with the specified number of items
163     * to display in the scrolling region, the specified scrolling interval, and
164     * the specified numbers of items fixed at the top and bottom of the popup
165     * menu.
166     *
167     * @param menu             the popup menu
168     * @param scrollCount      the number of items to display in the scrolling
169     *                         portion
170     * @param interval         the scroll interval, in milliseconds
171     * @param topFixedCount    the number of items to fix at the top. May be 0
172     * @param bottomFixedCount the number of items to fix at the bottom. May be
173     *                         0
174     * @throws IllegalArgumentException if scrollCount or interval is 0 or
175     *                                  negative or if topFixedCount or
176     *                                  bottomFixedCount is negative
177     * @return the MenuScroller
178     */
179    public static MenuScroller setScrollerFor(JPopupMenu menu, int scrollCount, int interval,
180            int topFixedCount, int bottomFixedCount) {
181        return new MenuScroller(menu, scrollCount, interval,
182                topFixedCount, bottomFixedCount);
183    }
184
185    /**
186     * Construct a <code>MenuScroller</code> that scrolls a menu with the
187     * default number of items to display at a time, and default scrolling
188     * interval.
189     *
190     * @param menu the menu
191     */
192    public MenuScroller(JMenu menu) {
193        this(menu, 15);
194    }
195
196    /**
197     * Construct a <code>MenuScroller</code> that scrolls a popup menu with the
198     * default number of items to display at a time, and default scrolling
199     * interval.
200     *
201     * @param menu the popup menu
202     */
203    public MenuScroller(JPopupMenu menu) {
204        this(menu, 15);
205    }
206
207    /**
208     * Construct a <code>MenuScroller</code> that scrolls a menu with the
209     * specified number of items to display at a time, and default scrolling
210     * interval.
211     *
212     * @param menu        the menu
213     * @param scrollCount the number of items to display at a time
214     * @throws IllegalArgumentException if scrollCount is 0 or negative
215     */
216    public MenuScroller(JMenu menu, int scrollCount) {
217        this(menu, scrollCount, 150);
218    }
219
220    /**
221     * Construct a <code>MenuScroller</code> that scrolls a popup menu with the
222     * specified number of items to display at a time, and default scrolling
223     * interval.
224     *
225     * @param menu        the popup menu
226     * @param scrollCount the number of items to display at a time
227     * @throws IllegalArgumentException if scrollCount is 0 or negative
228     */
229    public MenuScroller(JPopupMenu menu, int scrollCount) {
230        this(menu, scrollCount, 150);
231    }
232
233    /**
234     * Construct a <code>MenuScroller</code> that scrolls a menu with the
235     * specified number of items to display at a time, and specified scrolling
236     * interval.
237     *
238     * @param menu        the menu
239     * @param scrollCount the number of items to display at a time
240     * @param interval    the scroll interval, in milliseconds
241     * @throws IllegalArgumentException if scrollCount or interval is 0 or
242     *                                  negative
243     */
244    public MenuScroller(JMenu menu, int scrollCount, int interval) {
245        this(menu, scrollCount, interval, 0, 0);
246    }
247
248    /**
249     * Construct a <code>MenuScroller</code> that scrolls a popup menu with the
250     * specified number of items to display at a time, and specified scrolling
251     * interval.
252     *
253     * @param menu        the popup menu
254     * @param scrollCount the number of items to display at a time
255     * @param interval    the scroll interval, in milliseconds
256     * @throws IllegalArgumentException if scrollCount or interval is 0 or
257     *                                  negative
258     */
259    public MenuScroller(JPopupMenu menu, int scrollCount, int interval) {
260        this(menu, scrollCount, interval, 0, 0);
261    }
262
263    /**
264     * Construct a <code>MenuScroller</code> that scrolls a menu with the
265     * specified number of items to display in the scrolling region, the
266     * specified scrolling interval, and the specified numbers of items fixed at
267     * the top and bottom of the menu.
268     *
269     * @param menu             the menu
270     * @param scrollCount      the number of items to display in the scrolling
271     *                         portion
272     * @param interval         the scroll interval, in milliseconds
273     * @param topFixedCount    the number of items to fix at the top. May be 0
274     * @param bottomFixedCount the number of items to fix at the bottom. May be
275     *                         0
276     * @throws IllegalArgumentException if scrollCount or interval is 0 or
277     *                                  negative or if topFixedCount or
278     *                                  bottomFixedCount is negative
279     */
280    public MenuScroller(JMenu menu, int scrollCount, int interval,
281            int topFixedCount, int bottomFixedCount) {
282        this(menu.getPopupMenu(), scrollCount, interval, topFixedCount, bottomFixedCount);
283    }
284
285    /**
286     * Construct a <code>MenuScroller</code> that scrolls a popup menu with the
287     * specified number of items to display in the scrolling region, the
288     * specified scrolling interval, and the specified numbers of items fixed at
289     * the top and bottom of the popup menu.
290     *
291     * @param menu             the popup menu
292     * @param scrollCount      the number of items to display in the scrolling
293     *                         portion
294     * @param interval         the scroll interval, in milliseconds
295     * @param topFixedCount    the number of items to fix at the top. May be 0
296     * @param bottomFixedCount the number of items to fix at the bottom. May be
297     *                         0
298     * @throws IllegalArgumentException if scrollCount or interval is 0 or
299     *                                  negative or if topFixedCount or
300     *                                  bottomFixedCount is negative
301     */
302    public MenuScroller(JPopupMenu menu, int scrollCount, int interval,
303            int topFixedCount, int bottomFixedCount) {
304        if (scrollCount <= 0 || interval <= 0) {
305            throw new IllegalArgumentException("scrollCount and interval must be greater than 0");
306        }
307        if (topFixedCount < 0 || bottomFixedCount < 0) {
308            throw new IllegalArgumentException("topFixedCount and bottomFixedCount cannot be negative");
309        }
310
311        upItem = new MenuScrollerItem(MenuScrollerIcon.UP, -1);
312        downItem = new MenuScrollerItem(MenuScrollerIcon.DOWN, +1);
313        setScrollCount(scrollCount);
314        setInterval(interval);
315        setTopFixedCount(topFixedCount);
316        setBottomFixedCount(bottomFixedCount);
317
318        this.menu = menu;
319
320        installListeners();
321    }
322
323    private void installListeners() {
324
325        // remove all menu key listeners
326        for (MenuKeyListener mkl : menu.getMenuKeyListeners()) {
327            menu.removeMenuKeyListener(mkl);
328        }
329
330        // add our menu key listener
331        menu.addMenuKeyListener(menuScrollerMenuKeyListener);
332
333        // add a Popup Menu listener
334        menu.addPopupMenuListener(menuScrollerPopupMenuListener);
335
336        // add my mouse wheel listener
337        // (so mouseWheelMoved (below) will be called)
338        menu.addMouseWheelListener(this);
339    }
340
341    @Override
342    public void mouseWheelMoved(MouseWheelEvent e) {
343        // compute how much to scroll the menu
344        int amount = e.getScrollAmount() * e.getWheelRotation();
345        firstIndex += amount;
346        refreshMenu();
347        e.consume();
348    }
349
350    /**
351     * Return the scroll interval in milliseconds.
352     *
353     * @return the scroll interval in milliseconds
354     */
355    public int getInterval() {
356        return interval;
357    }
358
359    /**
360     * Set the scroll interval in milliseconds.
361     *
362     * @param interval the scroll interval in milliseconds
363     * @throws IllegalArgumentException if interval is 0 or negative
364     */
365    public void setInterval(int interval) {
366        if (interval <= 0) {
367            throw new IllegalArgumentException("interval must be greater than 0");
368        }
369        upItem.setInterval(interval);
370        downItem.setInterval(interval);
371        this.interval = interval;
372    }
373
374    /**
375     * Return the number of items in the scrolling portion of the menu.
376     *
377     * @return the number of items to display at a time
378     */
379    public int getscrollCount() {
380        return scrollCount;
381    }
382
383    /**
384     * Set the number of items in the scrolling portion of the menu.
385     *
386     * @param scrollCount the number of items to display at a time
387     * @throws IllegalArgumentException if scrollCount is 0 or negative
388     */
389    public void setScrollCount(int scrollCount) {
390        if (scrollCount <= 0) {
391            throw new IllegalArgumentException("scrollCount must be greater than 0");
392        }
393        this.scrollCount = scrollCount;
394        MenuSelectionManager.defaultManager().clearSelectedPath();
395    }
396
397    /**
398     * Return the number of items fixed at the top of the menu or popup menu.
399     *
400     * @return the number of items
401     */
402    public int getTopFixedCount() {
403        return topFixedCount;
404    }
405
406    /**
407     * Set the number of items to fix at the top of the menu or popup menu.
408     *
409     * @param topFixedCount the number of items
410     */
411    public void setTopFixedCount(int topFixedCount) {
412        if (firstIndex <= topFixedCount) {
413            firstIndex = topFixedCount;
414        } else {
415            firstIndex += (topFixedCount - this.topFixedCount);
416        }
417        this.topFixedCount = topFixedCount;
418    }
419
420    /**
421     * Return the number of items fixed at the bottom of the menu or popup
422     * menu.
423     *
424     * @return the number of items
425     */
426    public int getBottomFixedCount() {
427        return bottomFixedCount;
428    }
429
430    /**
431     * Set the number of items to fix at the bottom of the menu or popup menu.
432     *
433     * @param bottomFixedCount the number of items
434     */
435    public void setBottomFixedCount(int bottomFixedCount) {
436        this.bottomFixedCount = bottomFixedCount;
437    }
438
439    /**
440     * Scroll the specified item into view each time the menu is opened. Call
441     * this method with <code>null</code> to restore the default behavior, which
442     * is to show the menu as it last appeared.
443     *
444     * @param item the item to keep visible
445     * @see #keepVisible(int)
446     */
447    public void keepVisible(JMenuItem item) {
448        if (item == null) {
449            keepVisibleIndex = -1;
450        } else {
451            int index = menu.getComponentIndex(item);
452            keepVisibleIndex = index;
453        }
454    }
455
456    /**
457     * Scroll the item at the specified index into view each time the menu is
458     * opened. Call this method with <code>-1</code> to restore the default
459     * behavior, which is to show the menu as it last appeared.
460     *
461     * @param index the index of the item to keep visible
462     * @see #keepVisible(javax.swing.JMenuItem)
463     */
464    public void keepVisible(int index) {
465        keepVisibleIndex = index;
466    }
467
468    private void refreshMenu() {
469        if (menuItems != null && menuItems.length > 0) {
470            firstIndex = Math.max(topFixedCount, firstIndex);
471            firstIndex = Math.min(menuItems.length - bottomFixedCount - scrollCount, firstIndex);
472
473            upItem.setEnabled(firstIndex > topFixedCount);
474            downItem.setEnabled(firstIndex + scrollCount < menuItems.length - bottomFixedCount);
475
476            menu.removeAll();
477            for (int i = 0; i < topFixedCount; i++) {
478                menu.add(menuItems[i]);
479            }
480            if (topFixedCount > 0) {
481                menu.addSeparator();
482            }
483
484            menu.add(upItem);
485            for (int i = firstIndex; i < scrollCount + firstIndex; i++) {
486                menu.add(menuItems[i]);
487            }
488            menu.add(downItem);
489
490            if (bottomFixedCount > 0) {
491                menu.addSeparator();
492            }
493            for (int i = menuItems.length - bottomFixedCount; i < menuItems.length; i++) {
494                menu.add(menuItems[i]);
495            }
496
497            int maxPreferredWidth = 0;
498            for (Component item : menuItems) {
499                maxPreferredWidth = Math.max(maxPreferredWidth, item.getPreferredSize().width);
500            }
501            menu.setPreferredSize(new Dimension(maxPreferredWidth, menu.getPreferredSize().height));
502
503            java.awt.Container cont = upItem.getParent();
504            if (cont instanceof JComponent) {
505                ((JComponent) cont).revalidate();
506                cont.repaint();
507            }
508        }
509    }
510
511    private class MenuScrollerPopupMenuListener implements PopupMenuListener {
512
513        @Override
514        public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
515            setMenuItems();
516        }
517
518        @Override
519        public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
520            restoreMenuItems();
521        }
522
523        @Override
524        public void popupMenuCanceled(PopupMenuEvent e) {
525            restoreMenuItems();
526        }
527
528        private void setMenuItems() {
529            menuItems = menu.getComponents();
530
531            if (keepVisibleIndex >= topFixedCount
532                    && keepVisibleIndex <= menuItems.length - bottomFixedCount
533                    && (keepVisibleIndex > firstIndex + scrollCount
534                    || keepVisibleIndex < firstIndex)) {
535                firstIndex = Math.min(firstIndex, keepVisibleIndex);
536                firstIndex = Math.max(firstIndex, keepVisibleIndex - scrollCount + 1);
537            }
538            if (menuItems.length > topFixedCount + scrollCount + bottomFixedCount) {
539                refreshMenu();
540            }
541        }
542
543        private void restoreMenuItems() {
544            menu.removeAll();
545            for (Component c : menuItems) {
546                menu.add(c);
547            }
548        }
549    }
550
551    private class MenuScrollerTimer extends Timer {
552
553        public MenuScrollerTimer(final int increment, int interval) {
554            super(interval, new ActionListener() {
555
556                @Override
557                public void actionPerformed(ActionEvent e) {
558                    firstIndex += increment;
559                    refreshMenu();
560                }
561            });
562        }
563    }
564
565    private class MenuScrollerItem extends JMenuItem
566            implements ChangeListener {
567
568        private final MenuScrollerTimer timer;
569
570        public MenuScrollerItem(MenuScrollerIcon icon, int increment) {
571            setIcon(icon);
572            setDisabledIcon(icon);
573            timer = new MenuScrollerTimer(increment, interval);
574            addChangeListener(this);
575        }
576
577        public void setInterval(int interval) {
578            timer.setDelay(interval);
579        }
580
581        @Override
582        public void stateChanged(ChangeEvent e) {
583            if (isArmed() && !timer.isRunning()) {
584                timer.start();
585            }
586            if (!isArmed() && timer.isRunning()) {
587                timer.stop();
588            }
589        }
590    }   // class MenuScrollerItem
591
592    // TODO: Determine why these methods are not being called
593    private class MenuScrollerMenuKeyListener implements MenuKeyListener {
594
595        @Override
596        public void menuKeyTyped(MenuKeyEvent e) {
597            int keyCode = e.getKeyCode();
598            log.debug("MenuScroller.keyTyped({})", keyCode);
599        }
600
601        @Override
602        public void menuKeyPressed(MenuKeyEvent e) {
603            int keyCode = e.getKeyCode();
604            log.debug("MenuScroller.keyPressed({})", keyCode);
605        }
606
607        @Override
608        public void menuKeyReleased(MenuKeyEvent e) {
609            int keyCode = e.getKeyCode();
610            switch (keyCode) {
611                case KeyEvent.VK_UP: {
612                    log.debug("MenuScroller.keyReleased(VK_UP)");
613                    firstIndex--;
614                    refreshMenu();
615                    e.consume();
616                    break;
617                }
618
619                case KeyEvent.VK_DOWN: {
620                    log.debug("MenuScroller.keyReleased(VK_DOWN)");
621                    firstIndex++;
622                    refreshMenu();
623                    e.consume();
624                    break;
625                }
626
627                default: {
628                    log.debug("MenuScroller.keyReleased({})", keyCode);
629                    break;
630                }
631            }   //switch
632        }
633    }
634
635    private static enum MenuScrollerIcon implements Icon {
636
637        UP(9, 1, 9),
638        DOWN(1, 9, 1);
639        final int[] xPoints = {1, 5, 9};
640        final int[] yPoints;
641
642        MenuScrollerIcon(int... yPoints) {
643            this.yPoints = yPoints;
644        }
645
646        @Override
647        public void paintIcon(Component c, Graphics g, int x, int y) {
648            Dimension size = c.getSize();
649            Graphics g2 = g.create(size.width / 2 - 5, size.height / 2 - 5, 10, 10);
650            g2.setColor(Color.GRAY);
651            g2.drawPolygon(xPoints, yPoints, 3);
652            if (c.isEnabled()) {
653                g2.setColor(Color.BLACK);
654                g2.fillPolygon(xPoints, yPoints, 3);
655            }
656            g2.dispose();
657        }
658
659        @Override
660        public int getIconWidth() {
661            return 0;
662        }
663
664        @Override
665        public int getIconHeight() {
666            return 10;
667        }
668    }
669
670    private final static Logger log = LoggerFactory.getLogger(MenuScroller.class);
671
672}