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