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.gui.util.WindowGeometry;
032import org.openstreetmap.josm.tools.Logging;
033
034/**
035 * A class that provides scrolling capabilities to a long menu dropdown or
036 * popup menu. A number of items can optionally be frozen at the top of the menu.
037 * <p>
038 * <b>Implementation note:</B>  The default scrolling interval is 150 milliseconds.
039 * <p>
040 * @author Darryl, https://tips4java.wordpress.com/2009/02/01/menu-scroller/
041 * @since 4593
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 topFixedCount;
052    private int firstIndex;
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     * @return the MenuScroller
154     * @throws IllegalArgumentException if scrollCount or interval is 0 or
155     * negative or if topFixedCount is negative
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     * @return the MenuScroller
170     * @throws IllegalArgumentException if scrollCount or interval is 0 or
171     * negative or if topFixedCount is negative
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, interval);
263        downItem = new MenuScrollItem(MenuIcon.DOWN, +1, interval);
264        setTopFixedCount(topFixedCount);
265
266        this.menu = menu;
267        menu.addPopupMenuListener(menuListener);
268        menu.addMouseWheelListener(mouseWheelListener);
269    }
270
271    /**
272     * Returns the number of items fixed at the top of the menu or popup menu.
273     *
274     * @return the number of items
275     */
276    public int getTopFixedCount() {
277        return topFixedCount;
278    }
279
280    /**
281     * Sets the number of items to fix at the top of the menu or popup menu.
282     *
283     * @param topFixedCount the number of items
284     */
285    public void setTopFixedCount(int topFixedCount) {
286        if (firstIndex <= topFixedCount) {
287            firstIndex = topFixedCount;
288        } else {
289            firstIndex += (topFixedCount - this.topFixedCount);
290        }
291        this.topFixedCount = topFixedCount;
292    }
293
294    /**
295     * Removes this MenuScroller from the associated menu and restores the
296     * default behavior of the menu.
297     */
298    public void dispose() {
299        if (menu != null) {
300            menu.removePopupMenuListener(menuListener);
301            menu.removeMouseWheelListener(mouseWheelListener);
302            menu.setPreferredSize(null);
303            menu = null;
304        }
305    }
306
307    private void refreshMenu() {
308        if (menuItems != null && menuItems.length > 0) {
309
310            int allItemsHeight = 0;
311            for (Component item : menuItems) {
312                allItemsHeight += item.getPreferredSize().height;
313            }
314
315            int allowedHeight = WindowGeometry.getMaxDimensionOnScreen(menu).height - ((JFrame) Main.parent).getInsets().top;
316
317            boolean mustSCroll = allItemsHeight > allowedHeight;
318
319            if (mustSCroll) {
320                firstIndex = Math.max(topFixedCount, firstIndex);
321                int scrollCount = computeScrollCount(firstIndex);
322                firstIndex = Math.min(menuItems.length - scrollCount, firstIndex);
323
324                upItem.setEnabled(firstIndex > topFixedCount);
325                downItem.setEnabled(firstIndex + scrollCount < menuItems.length);
326
327                menu.removeAll();
328                for (int i = 0; i < topFixedCount; i++) {
329                    menu.add(menuItems[i]);
330                }
331                if (topFixedCount > 0) {
332                    menu.addSeparator();
333                }
334
335                menu.add(upItem);
336                for (int i = firstIndex; i < scrollCount + firstIndex; i++) {
337                    menu.add(menuItems[i]);
338                }
339                menu.add(downItem);
340
341                int preferredWidth = 0;
342                for (Component item : menuItems) {
343                    preferredWidth = Math.max(preferredWidth, item.getPreferredSize().width);
344                }
345                menu.setPreferredSize(new Dimension(preferredWidth, menu.getPreferredSize().height));
346
347            } else if (!Arrays.equals(menu.getComponents(), menuItems)) {
348                // Scroll is not needed but menu is not up to date
349                menu.removeAll();
350                for (Component item : menuItems) {
351                    menu.add(item);
352                }
353            }
354
355            menu.revalidate();
356            menu.repaint();
357        }
358    }
359
360    private class MenuScrollListener implements PopupMenuListener {
361
362        @Override
363        public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
364            setMenuItems();
365        }
366
367        @Override
368        public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
369            restoreMenuItems();
370        }
371
372        @Override
373        public void popupMenuCanceled(PopupMenuEvent e) {
374            restoreMenuItems();
375        }
376
377        private void setMenuItems() {
378            menuItems = menu.getComponents();
379            refreshMenu();
380        }
381
382        private void restoreMenuItems() {
383            menu.removeAll();
384            for (Component component : menuItems) {
385                menu.add(component);
386            }
387        }
388    }
389
390    private class MenuScrollTimer extends Timer {
391
392        MenuScrollTimer(final int increment, int interval) {
393            super(interval, new ActionListener() {
394
395                @Override
396                public void actionPerformed(ActionEvent e) {
397                    firstIndex += increment;
398                    refreshMenu();
399                }
400            });
401        }
402    }
403
404    private class MenuScrollItem extends JMenuItem
405            implements ChangeListener {
406
407        private final MenuScrollTimer timer;
408
409        MenuScrollItem(MenuIcon icon, int increment, int interval) {
410            setIcon(icon);
411            setDisabledIcon(icon);
412            timer = new MenuScrollTimer(increment, interval);
413            addChangeListener(this);
414        }
415
416        @Override
417        public void stateChanged(ChangeEvent e) {
418            if (isArmed() && !timer.isRunning()) {
419                timer.start();
420            }
421            if (!isArmed() && timer.isRunning()) {
422                timer.stop();
423            }
424        }
425    }
426
427    private enum MenuIcon implements Icon {
428
429        UP(9, 1, 9),
430        DOWN(1, 9, 1);
431        private static final int[] XPOINTS = {1, 5, 9};
432        private final int[] yPoints;
433
434        MenuIcon(int... yPoints) {
435            this.yPoints = yPoints;
436        }
437
438        @Override
439        public void paintIcon(Component c, Graphics g, int x, int y) {
440            Dimension size = c.getSize();
441            Graphics g2 = g.create(size.width / 2 - 5, size.height / 2 - 5, 10, 10);
442            g2.setColor(Color.GRAY);
443            g2.drawPolygon(XPOINTS, yPoints, 3);
444            if (c.isEnabled()) {
445                g2.setColor(Color.BLACK);
446                g2.fillPolygon(XPOINTS, yPoints, 3);
447            }
448            g2.dispose();
449        }
450
451        @Override
452        public int getIconWidth() {
453            return 0;
454        }
455
456        @Override
457        public int getIconHeight() {
458            return ARROW_ICON_HEIGHT;
459        }
460    }
461
462    private class MouseScrollListener implements MouseWheelListener {
463        @Override
464        public void mouseWheelMoved(MouseWheelEvent mwe) {
465            firstIndex += mwe.getWheelRotation();
466            refreshMenu();
467            if (Logging.isDebugEnabled()) {
468                Logging.debug("{0} consuming event {1}", getClass().getName(), mwe);
469            }
470            mwe.consume();
471        }
472    }
473}