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