001/**
002 * MenuScroller.java    1.5.0 04/02/12
003 * License: use / modify without restrictions (see https://tips4java.wordpress.com/about/)
004 * Heavily modified for JOSM needs => drop unused features and replace static scrollcount approach by dynamic behaviour
005 */
006package org.openstreetmap.josm.gui;
007
008import java.awt.Color;
009import java.awt.Component;
010import java.awt.Dimension;
011import java.awt.Graphics;
012import java.awt.event.ActionEvent;
013import java.awt.event.ActionListener;
014import java.awt.event.MouseWheelEvent;
015import java.awt.event.MouseWheelListener;
016import java.util.Arrays;
017
018import javax.swing.Icon;
019import javax.swing.JFrame;
020import javax.swing.JMenu;
021import javax.swing.JMenuItem;
022import javax.swing.JPopupMenu;
023import javax.swing.JSeparator;
024import javax.swing.Timer;
025import javax.swing.event.ChangeEvent;
026import javax.swing.event.ChangeListener;
027import javax.swing.event.PopupMenuEvent;
028import javax.swing.event.PopupMenuListener;
029
030import org.openstreetmap.josm.Main;
031import org.openstreetmap.josm.tools.WindowGeometry;
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 of the menu.
036 * <p>
037 * <b>Implementation note:</B>  The default scrolling interval is 150 milliseconds.
038 * <p>
039 * @author Darryl, https://tips4java.wordpress.com/2009/02/01/menu-scroller/
040 * @since 4593
041 */
042public class MenuScroller {
043
044    private JPopupMenu menu;
045    private Component[] menuItems;
046    private MenuScrollItem upItem;
047    private MenuScrollItem downItem;
048    private final MenuScrollListener menuListener = new MenuScrollListener();
049    private final MouseWheelListener mouseWheelListener = new MouseScrollListener();
050    private int interval;
051    private int topFixedCount;
052    private int firstIndex = 0;
053
054    private static final int ARROW_ICON_HEIGHT = 10;
055
056    private int computeScrollCount(int startIndex) {
057        int result = 15;
058        if (menu != null) {
059            // Compute max height of current screen
060            int maxHeight = WindowGeometry.getMaxDimensionOnScreen(menu).height - ((JFrame)Main.parent).getInsets().top;
061
062            // Remove top fixed part height
063            if (topFixedCount > 0) {
064                for (int i = 0; i < topFixedCount; i++) {
065                    maxHeight -= menuItems[i].getPreferredSize().height;
066                }
067                maxHeight -= new JSeparator().getPreferredSize().height;
068            }
069
070            // Remove height of our two arrow items + insets
071            maxHeight -= menu.getInsets().top;
072            maxHeight -= upItem.getPreferredSize().height;
073            maxHeight -= downItem.getPreferredSize().height;
074            maxHeight -= menu.getInsets().bottom;
075
076            // Compute scroll count
077            result = 0;
078            int height = 0;
079            for (int i = startIndex; i < menuItems.length && height <= maxHeight; i++, result++) {
080                height += menuItems[i].getPreferredSize().height;
081            }
082
083            if (height > maxHeight) {
084                // Remove extra item from count
085                result--;
086            } else {
087                // Increase scroll count to take into account upper items that will be displayed
088                // after firstIndex is updated
089                for (int i=startIndex-1; i >= 0 && height <= maxHeight; i--, result++) {
090                    height += menuItems[i].getPreferredSize().height;
091                }
092                if (height > maxHeight) {
093                    result--;
094                }
095            }
096        }
097        return result;
098    }
099
100    /**
101     * Registers a menu to be scrolled with the default scrolling interval.
102     *
103     * @param menu the menu
104     * @return the MenuScroller
105     */
106    public static MenuScroller setScrollerFor(JMenu menu) {
107        return new MenuScroller(menu);
108    }
109
110    /**
111     * Registers a popup menu to be scrolled with the default scrolling interval.
112     *
113     * @param menu the popup menu
114     * @return the MenuScroller
115     */
116    public static MenuScroller setScrollerFor(JPopupMenu menu) {
117        return new MenuScroller(menu);
118    }
119
120    /**
121     * Registers a menu to be scrolled, with the specified scrolling interval.
122     *
123     * @param menu the menu
124     * @param interval the scroll interval, in milliseconds
125     * @return the MenuScroller
126     * @throws IllegalArgumentException if scrollCount or interval is 0 or negative
127     * @since 7463
128     */
129    public static MenuScroller setScrollerFor(JMenu menu, int interval) {
130        return new MenuScroller(menu, interval);
131    }
132
133    /**
134     * Registers a popup menu to be scrolled, with the specified scrolling interval.
135     *
136     * @param menu the popup menu
137     * @param interval the scroll interval, in milliseconds
138     * @return the MenuScroller
139     * @throws IllegalArgumentException if scrollCount or interval is 0 or negative
140     * @since 7463
141     */
142    public static MenuScroller setScrollerFor(JPopupMenu menu, int interval) {
143        return new MenuScroller(menu, interval);
144    }
145
146    /**
147     * Registers a menu to be scrolled, with the specified scrolling interval,
148     * and the specified numbers of items fixed at the top of the menu.
149     *
150     * @param menu the menu
151     * @param interval the scroll interval, in milliseconds
152     * @param topFixedCount the number of items to fix at the top.  May be 0.
153     * @throws IllegalArgumentException if scrollCount or interval is 0 or
154     * negative or if topFixedCount is negative
155     * @return the MenuScroller
156     * @since 7463
157     */
158    public static MenuScroller setScrollerFor(JMenu menu, int interval, int topFixedCount) {
159        return new MenuScroller(menu, interval, topFixedCount);
160    }
161
162    /**
163     * Registers a popup menu to be scrolled, with the specified scrolling interval,
164     * and the specified numbers of items fixed at the top of the popup menu.
165     *
166     * @param menu the popup menu
167     * @param interval the scroll interval, in milliseconds
168     * @param topFixedCount the number of items to fix at the top. May be 0
169     * @throws IllegalArgumentException if scrollCount or interval is 0 or
170     * negative or if topFixedCount is negative
171     * @return the MenuScroller
172     * @since 7463
173     */
174    public static MenuScroller setScrollerFor(JPopupMenu menu, int interval, int topFixedCount) {
175        return new MenuScroller(menu, interval, topFixedCount);
176    }
177
178    /**
179     * Constructs a <code>MenuScroller</code> that scrolls a menu with the
180     * default scrolling interval.
181     *
182     * @param menu the menu
183     * @throws IllegalArgumentException if scrollCount is 0 or negative
184     */
185    public MenuScroller(JMenu menu) {
186        this(menu, 150);
187    }
188
189    /**
190     * Constructs a <code>MenuScroller</code> that scrolls a popup menu with the
191     * default scrolling interval.
192     *
193     * @param menu the popup menu
194     * @throws IllegalArgumentException if scrollCount is 0 or negative
195     */
196    public MenuScroller(JPopupMenu menu) {
197        this(menu, 150);
198    }
199
200    /**
201     * Constructs a <code>MenuScroller</code> that scrolls a menu with the
202     * specified scrolling interval.
203     *
204     * @param menu the menu
205     * @param interval the scroll interval, in milliseconds
206     * @throws IllegalArgumentException if scrollCount or interval is 0 or negative
207     * @since 7463
208     */
209    public MenuScroller(JMenu menu, int interval) {
210        this(menu, interval, 0);
211    }
212
213    /**
214     * Constructs a <code>MenuScroller</code> that scrolls a popup menu with the
215     * specified scrolling interval.
216     *
217     * @param menu the popup menu
218     * @param interval the scroll interval, in milliseconds
219     * @throws IllegalArgumentException if scrollCount or interval is 0 or negative
220     * @since 7463
221     */
222    public MenuScroller(JPopupMenu menu, int interval) {
223        this(menu, interval, 0);
224    }
225
226    /**
227     * Constructs a <code>MenuScroller</code> that scrolls a menu with the
228     * specified scrolling interval, and the specified numbers of items fixed at
229     * the top of the menu.
230     *
231     * @param menu the menu
232     * @param interval the scroll interval, in milliseconds
233     * @param topFixedCount the number of items to fix at the top.  May be 0
234     * @throws IllegalArgumentException if scrollCount or interval is 0 or
235     * negative or if topFixedCount is negative
236     * @since 7463
237     */
238    public MenuScroller(JMenu menu, int interval, int topFixedCount) {
239        this(menu.getPopupMenu(), interval, topFixedCount);
240    }
241
242    /**
243     * Constructs a <code>MenuScroller</code> that scrolls a popup menu with the
244     * specified scrolling interval, and the specified numbers of items fixed at
245     * the top of the popup menu.
246     *
247     * @param menu the popup menu
248     * @param interval the scroll interval, in milliseconds
249     * @param topFixedCount the number of items to fix at the top.  May be 0
250     * @throws IllegalArgumentException if scrollCount or interval is 0 or
251     * negative or if topFixedCount is negative
252     * @since 7463
253     */
254    public MenuScroller(JPopupMenu menu, int interval, int topFixedCount) {
255        if (interval <= 0) {
256            throw new IllegalArgumentException("interval must be greater than 0");
257        }
258        if (topFixedCount < 0) {
259            throw new IllegalArgumentException("topFixedCount cannot be negative");
260        }
261
262        upItem = new MenuScrollItem(MenuIcon.UP, -1);
263        downItem = new MenuScrollItem(MenuIcon.DOWN, +1);
264        setInterval(interval);
265        setTopFixedCount(topFixedCount);
266
267        this.menu = menu;
268        menu.addPopupMenuListener(menuListener);
269        menu.addMouseWheelListener(mouseWheelListener);
270    }
271
272    /**
273     * Returns the scroll interval in milliseconds
274     *
275     * @return the scroll interval in milliseconds
276     */
277    public int getInterval() {
278        return interval;
279    }
280
281    /**
282     * Sets the scroll interval in milliseconds
283     *
284     * @param interval the scroll interval in milliseconds
285     * @throws IllegalArgumentException if interval is 0 or negative
286     */
287    public void setInterval(int interval) {
288        if (interval <= 0) {
289            throw new IllegalArgumentException("interval must be greater than 0");
290        }
291        upItem.setInterval(interval);
292        downItem.setInterval(interval);
293        this.interval = interval;
294    }
295
296    /**
297     * Returns the number of items fixed at the top of the menu or popup menu.
298     *
299     * @return the number of items
300     */
301    public int getTopFixedCount() {
302        return topFixedCount;
303    }
304
305    /**
306     * Sets the number of items to fix at the top of the menu or popup menu.
307     *
308     * @param topFixedCount the number of items
309     */
310    public void setTopFixedCount(int topFixedCount) {
311        if (firstIndex <= topFixedCount) {
312            firstIndex = topFixedCount;
313        } else {
314            firstIndex += (topFixedCount - this.topFixedCount);
315        }
316        this.topFixedCount = topFixedCount;
317    }
318
319    /**
320     * Removes this MenuScroller from the associated menu and restores the
321     * default behavior of the menu.
322     */
323    public void dispose() {
324        if (menu != null) {
325            menu.removePopupMenuListener(menuListener);
326            menu.removeMouseWheelListener(mouseWheelListener);
327            menu.setPreferredSize(null);
328            menu = null;
329        }
330    }
331
332    /**
333     * Ensures that the <code>dispose</code> method of this MenuScroller is
334     * called when there are no more refrences to it.
335     *
336     * @exception  Throwable if an error occurs.
337     * @see MenuScroller#dispose()
338     */
339    @Override
340    protected void finalize() throws Throwable {
341        dispose();
342        super.finalize();
343    }
344
345    private void refreshMenu() {
346        if (menuItems != null && menuItems.length > 0) {
347
348            int allItemsHeight = 0;
349            for (Component item : menuItems) {
350                allItemsHeight += item.getPreferredSize().height;
351            }
352
353            int allowedHeight = WindowGeometry.getMaxDimensionOnScreen(menu).height - ((JFrame)Main.parent).getInsets().top;
354
355            boolean mustSCroll = allItemsHeight > allowedHeight;
356
357            if (mustSCroll) {
358                firstIndex = Math.max(topFixedCount, firstIndex);
359                int scrollCount = computeScrollCount(firstIndex);
360                firstIndex = Math.min(menuItems.length - scrollCount, firstIndex);
361
362                upItem.setEnabled(firstIndex > topFixedCount);
363                downItem.setEnabled(firstIndex + scrollCount < menuItems.length);
364
365                menu.removeAll();
366                for (int i = 0; i < topFixedCount; i++) {
367                    menu.add(menuItems[i]);
368                }
369                if (topFixedCount > 0) {
370                    menu.addSeparator();
371                }
372
373                menu.add(upItem);
374                for (int i = firstIndex; i < scrollCount + firstIndex; i++) {
375                    menu.add(menuItems[i]);
376                }
377                menu.add(downItem);
378
379                int preferredWidth = 0;
380                for (Component item : menuItems) {
381                    preferredWidth = Math.max(preferredWidth, item.getPreferredSize().width);
382                }
383                menu.setPreferredSize(new Dimension(preferredWidth, menu.getPreferredSize().height));
384
385            } else if (!Arrays.equals(menu.getComponents(), menuItems)) {
386                // Scroll is not needed but menu is not up to date
387                menu.removeAll();
388                for (Component item : menuItems) {
389                    menu.add(item);
390                }
391            }
392
393            menu.revalidate();
394            menu.repaint();
395        }
396    }
397
398    private class MenuScrollListener implements PopupMenuListener {
399
400        @Override
401        public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
402            setMenuItems();
403        }
404
405        @Override
406        public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
407            restoreMenuItems();
408        }
409
410        @Override
411        public void popupMenuCanceled(PopupMenuEvent e) {
412            restoreMenuItems();
413        }
414
415        private void setMenuItems() {
416            menuItems = menu.getComponents();
417            refreshMenu();
418        }
419
420        private void restoreMenuItems() {
421            menu.removeAll();
422            for (Component component : menuItems) {
423                menu.add(component);
424            }
425        }
426    }
427
428    private class MenuScrollTimer extends Timer {
429
430        public MenuScrollTimer(final int increment, int interval) {
431            super(interval, new ActionListener() {
432
433                @Override
434                public void actionPerformed(ActionEvent e) {
435                    firstIndex += increment;
436                    refreshMenu();
437                }
438            });
439        }
440    }
441
442    private class MenuScrollItem extends JMenuItem
443            implements ChangeListener {
444
445        private MenuScrollTimer timer;
446
447        public MenuScrollItem(MenuIcon icon, int increment) {
448            setIcon(icon);
449            setDisabledIcon(icon);
450            timer = new MenuScrollTimer(increment, interval);
451            addChangeListener(this);
452        }
453
454        public void setInterval(int interval) {
455            timer.setDelay(interval);
456        }
457
458        @Override
459        public void stateChanged(ChangeEvent e) {
460            if (isArmed() && !timer.isRunning()) {
461                timer.start();
462            }
463            if (!isArmed() && timer.isRunning()) {
464                timer.stop();
465            }
466        }
467    }
468
469    private static enum MenuIcon implements Icon {
470
471        UP(9, 1, 9),
472        DOWN(1, 9, 1);
473        static final int[] XPOINTS = {1, 5, 9};
474        final int[] yPoints;
475
476        MenuIcon(int... yPoints) {
477            this.yPoints = yPoints;
478        }
479
480        @Override
481        public void paintIcon(Component c, Graphics g, int x, int y) {
482            Dimension size = c.getSize();
483            Graphics g2 = g.create(size.width / 2 - 5, size.height / 2 - 5, 10, 10);
484            g2.setColor(Color.GRAY);
485            g2.drawPolygon(XPOINTS, yPoints, 3);
486            if (c.isEnabled()) {
487                g2.setColor(Color.BLACK);
488                g2.fillPolygon(XPOINTS, yPoints, 3);
489            }
490            g2.dispose();
491        }
492
493        @Override
494        public int getIconWidth() {
495            return 0;
496        }
497
498        @Override
499        public int getIconHeight() {
500            return ARROW_ICON_HEIGHT;
501        }
502    }
503
504    private class MouseScrollListener implements MouseWheelListener {
505        @Override
506        public void mouseWheelMoved(MouseWheelEvent mwe) {
507            firstIndex += mwe.getWheelRotation();
508            refreshMenu();
509            mwe.consume();
510        }
511    }
512}