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