001/*
002 * This file is part of McIDAS-V
003 *
004 * Copyright 2007-2024
005 * Space Science and Engineering Center (SSEC)
006 * University of Wisconsin - Madison
007 * 1225 W. Dayton Street, Madison, WI 53706, USA
008 * https://www.ssec.wisc.edu/mcidas/
009 * 
010 * All Rights Reserved
011 * 
012 * McIDAS-V is built on Unidata's IDV and SSEC's VisAD libraries, and
013 * some McIDAS-V source code is based on IDV and VisAD source code.  
014 * 
015 * McIDAS-V is free software; you can redistribute it and/or modify
016 * it under the terms of the GNU Lesser Public License as published by
017 * the Free Software Foundation; either version 3 of the License, or
018 * (at your option) any later version.
019 * 
020 * McIDAS-V is distributed in the hope that it will be useful,
021 * but WITHOUT ANY WARRANTY; without even the implied warranty of
022 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
023 * GNU Lesser Public License for more details.
024 * 
025 * You should have received a copy of the GNU Lesser Public License
026 * along with this program.  If not, see https://www.gnu.org/licenses/.
027 */
028
029package edu.wisc.ssec.mcidasv.ui;
030
031import java.awt.Color;
032import java.awt.Component;
033import java.awt.Dimension;
034import java.awt.Graphics;
035import java.awt.GraphicsConfiguration;
036import java.awt.Insets;
037import java.awt.Rectangle;
038import java.awt.event.MouseWheelEvent;
039import java.awt.event.MouseWheelListener;
040import java.util.Arrays;
041import java.util.EventListener;
042import java.util.Objects;
043
044import javax.swing.Icon;
045import javax.swing.JComponent;
046import javax.swing.JMenu;
047import javax.swing.JMenuItem;
048import javax.swing.JPopupMenu;
049import javax.swing.JSeparator;
050import javax.swing.Timer;
051import javax.swing.event.ChangeEvent;
052import javax.swing.event.ChangeListener;
053import javax.swing.event.PopupMenuEvent;
054import javax.swing.event.PopupMenuListener;
055
056
057/**
058 * A class that provides scrolling capabilities to a long menu dropdown or
059 * popup menu. A number of items can optionally be frozen at the top of the menu.
060 * <p>
061 * <b>Implementation note:</b>  The default scrolling interval is 150 milliseconds.
062 * <p>
063 * @author Darryl, https://tips4java.wordpress.com/2009/02/01/menu-scroller/
064 * @since 4593
065 *
066 * MenuScroller.java    1.5.0 04/02/12
067 * License: use / modify without restrictions (see https://tips4java.wordpress.com/about/)
068 * Heavily modified for JOSM needs =&gt; drop unused features and replace static scrollcount approach by dynamic behaviour
069 */
070public class MenuScroller {
071
072    private JComponent parent;
073    private JPopupMenu menu;
074    private Component[] menuItems;
075    private MenuScrollItem upItem;
076    private MenuScrollItem downItem;
077    private final MenuScrollListener menuListener = new MenuScrollListener();
078    private final MouseWheelListener mouseWheelListener = new MouseScrollListener();
079    private int interval;
080    private int topFixedCount;
081    private int firstIndex = 0;
082
083    private static final int ARROW_ICON_HEIGHT = 10;
084
085    /**
086     * Computes the maximum dimension for a component to fit in screen
087     * displaying {@code component}.
088     *
089     * @param component The component to get current screen info from.
090     * Must not be {@code null}
091     *
092     * @return Maximum dimension for a component to fit in current screen.
093     *
094     * @throws NullPointerException if {@code component} is {@code null}.
095     */
096    public static Dimension getMaxDimensionOnScreen(JComponent parent, JComponent component) {
097        Objects.requireNonNull(component, "component");
098        // Compute max dimension of current screen
099        Dimension result = new Dimension();
100        GraphicsConfiguration gc = component.getGraphicsConfiguration();
101        if ((gc == null) && (parent != null)) {
102            gc = parent.getGraphicsConfiguration();
103        }
104        if (gc != null) {
105            // Max displayable dimension (max screen dimension - insets)
106            Rectangle bounds = gc.getBounds();
107            Insets insets = component.getToolkit().getScreenInsets(gc);
108            result.width  = bounds.width  - insets.left - insets.right;
109            result.height = bounds.height - insets.top - insets.bottom;
110        }
111        return result;
112    }
113
114
115
116    private int computeScrollCount(int startIndex) {
117        int result = 15;
118        if (menu != null) {
119            // Compute max height of current screen
120//            Component parent = IdvWindow.getActiveWindow().getFrame();
121            int maxHeight = getMaxDimensionOnScreen(parent, menu).height - parent.getInsets().top;
122
123            // Remove top fixed part height
124            if (topFixedCount > 0) {
125                for (int i = 0; i < topFixedCount; i++) {
126                    maxHeight -= menuItems[i].getPreferredSize().height;
127                }
128                maxHeight -= new JSeparator().getPreferredSize().height;
129            }
130
131            // Remove height of our two arrow items + insets
132            maxHeight -= menu.getInsets().top;
133            maxHeight -= upItem.getPreferredSize().height;
134            maxHeight -= downItem.getPreferredSize().height;
135            maxHeight -= menu.getInsets().bottom;
136
137            // Compute scroll count
138            result = 0;
139            int height = 0;
140            for (int i = startIndex; (i < menuItems.length) && (height <= maxHeight); i++, result++) {
141                height += menuItems[i].getPreferredSize().height;
142            }
143
144            if (height > maxHeight) {
145                // Remove extra item from count
146                result--;
147            } else {
148                // Increase scroll count to take into account upper items that will be displayed
149                // after firstIndex is updated
150                for (int i = startIndex-1; (i >= 0) && (height <= maxHeight); i--, result++) {
151                    height += menuItems[i].getPreferredSize().height;
152                }
153                if (height > maxHeight) {
154                    result--;
155                }
156            }
157        }
158        return result;
159    }
160
161    /**
162     * Registers a menu to be scrolled with the default scrolling interval.
163     *
164     * @param menu Menu to
165     * @return the MenuScroller
166     */
167    public static MenuScroller setScrollerFor(JMenu menu) {
168        return new MenuScroller(menu);
169    }
170
171    /**
172     * Registers a popup menu to be scrolled with the default scrolling interval.
173     *
174     * @param menu the popup menu
175     * @return the MenuScroller
176     */
177    public static MenuScroller setScrollerFor(JPopupMenu menu) {
178        return new MenuScroller(menu);
179    }
180
181    /**
182     * Registers a menu to be scrolled, with the specified scrolling interval.
183     *
184     * @param menu the menu
185     * @param interval the scroll interval, in milliseconds
186     * @return the MenuScroller
187     * @throws IllegalArgumentException if scrollCount or interval is 0 or negative
188     */
189    public static MenuScroller setScrollerFor(JMenu menu, int interval) {
190        return new MenuScroller(menu, interval);
191    }
192
193    /**
194     * Registers a popup menu to be scrolled, with the specified scrolling interval.
195     *
196     * @param menu the popup menu
197     * @param interval the scroll interval, in milliseconds
198     * @return the MenuScroller
199     * @throws IllegalArgumentException if scrollCount or interval is 0 or negative
200     */
201    public static MenuScroller setScrollerFor(JPopupMenu menu, int interval) {
202        return new MenuScroller(menu, interval);
203    }
204
205    /**
206     * Registers a menu to be scrolled, with the specified scrolling interval,
207     * and the specified numbers of items fixed at the top of the menu.
208     *
209     * @param menu the menu
210     * @param interval the scroll interval, in milliseconds
211     * @param topFixedCount the number of items to fix at the top.  May be 0.
212     * @throws IllegalArgumentException if scrollCount or interval is 0 or
213     * negative or if topFixedCount is negative
214     * @return the MenuScroller
215     */
216    public static MenuScroller setScrollerFor(JMenu menu, int interval, int topFixedCount) {
217        return new MenuScroller(menu, interval, topFixedCount);
218    }
219
220    /**
221     * Registers a popup menu to be scrolled, with the specified scrolling interval,
222     * and the specified numbers of items fixed at the top of the popup menu.
223     *
224     * @param menu the popup menu
225     * @param interval the scroll interval, in milliseconds
226     * @param topFixedCount the number of items to fix at the top. May be 0
227     * @throws IllegalArgumentException if scrollCount or interval is 0 or
228     * negative or if topFixedCount is negative
229     * @return the MenuScroller
230     */
231    public static MenuScroller setScrollerFor(JPopupMenu menu, int interval, int topFixedCount) {
232        return new MenuScroller(menu, interval, topFixedCount);
233    }
234
235    /**
236     * Constructs a {@code MenuScroller} that scrolls a menu with the
237     * default scrolling interval.
238     *
239     * @param menu the menu
240     * @throws IllegalArgumentException if scrollCount is 0 or negative
241     */
242    public MenuScroller(JMenu menu) {
243        this(menu, 150);
244    }
245
246    public MenuScroller(JComponent parentComp, JMenu menu) {
247        this(menu, 150);
248        parent = parentComp;
249    }
250
251    /**
252     * Constructs a {@code MenuScroller} that scrolls a popup menu with the
253     * default scrolling interval.
254     *
255     * @param menu the popup menu
256     * @throws IllegalArgumentException if scrollCount is 0 or negative
257     */
258    public MenuScroller(JPopupMenu menu) {
259        this(menu, 150);
260    }
261
262    /**
263     * Constructs a {@code MenuScroller} that scrolls a menu with the
264     * specified scrolling interval.
265     *
266     * @param menu the menu
267     * @param interval the scroll interval, in milliseconds
268     * @throws IllegalArgumentException if scrollCount or interval is 0 or negative
269     */
270    public MenuScroller(JMenu menu, int interval) {
271        this(menu, interval, 0);
272    }
273
274    /**
275     * Constructs a {@code MenuScroller} that scrolls a popup menu with the
276     * specified scrolling interval.
277     *
278     * @param menu the popup menu
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 interval) {
283        this(menu, interval, 0);
284    }
285
286    public MenuScroller(JComponent parentComp, JMenu menu, int interval) {
287        this(menu, interval, 0);
288        parent = parentComp;
289    }
290
291    /**
292     * Constructs a {@code MenuScroller} that scrolls a menu with the
293     * specified scrolling interval, and the specified numbers of items fixed at
294     * the top of the menu.
295     *
296     * @param menu the menu
297     * @param interval the scroll interval, in milliseconds
298     * @param topFixedCount the number of items to fix at the top.  May be 0
299     * @throws IllegalArgumentException if scrollCount or interval is 0 or
300     * negative or if topFixedCount is negative
301     */
302    public MenuScroller(JMenu menu, int interval, int topFixedCount) {
303        this(menu.getPopupMenu(), interval, topFixedCount);
304    }
305
306    public MenuScroller(JComponent parentComp, JMenu menu, int interval, int topFixedCount) {
307        this(menu.getPopupMenu(), interval, topFixedCount);
308        parent = parentComp;
309    }
310
311    /**
312     * Constructs a {@code MenuScroller} that scrolls a popup menu with the
313     * specified scrolling interval, and the specified numbers of items fixed at
314     * the top of the popup menu.
315     *
316     * @param menu the popup menu
317     * @param interval the scroll interval, in milliseconds
318     * @param topFixedCount the number of items to fix at the top.  May be 0
319     * @throws IllegalArgumentException if scrollCount or interval is 0 or
320     * negative or if topFixedCount is negative
321     */
322    public MenuScroller(JPopupMenu menu, int interval, int topFixedCount) {
323        if (interval <= 0) {
324            throw new IllegalArgumentException("interval must be greater than 0");
325        }
326        if (topFixedCount < 0) {
327            throw new IllegalArgumentException("topFixedCount cannot be negative");
328        }
329
330        upItem = new MenuScrollItem(MenuIcon.UP, -1);
331        downItem = new MenuScrollItem(MenuIcon.DOWN, +1);
332        setInterval(interval);
333        setTopFixedCount(topFixedCount);
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 fixed at the top of the menu or popup menu.
366     *
367     * @return the number of items
368     */
369    public int getTopFixedCount() {
370        return topFixedCount;
371    }
372
373    /**
374     * Sets the number of items to fix at the top of the menu or popup menu.
375     *
376     * @param topFixedCount the number of items
377     */
378    public void setTopFixedCount(int topFixedCount) {
379        if (firstIndex <= topFixedCount) {
380            firstIndex = topFixedCount;
381        } else {
382            firstIndex += (topFixedCount - this.topFixedCount);
383        }
384        this.topFixedCount = topFixedCount;
385    }
386
387    /**
388     * Removes this MenuScroller from the associated menu and restores the
389     * default behavior of the menu.
390     */
391    public void dispose() {
392        if (menu != null) {
393            menu.removePopupMenuListener(menuListener);
394            menu.removeMouseWheelListener(mouseWheelListener);
395            menu.setPreferredSize(null);
396            menu = null;
397        }
398    }
399
400    public void resetMenu() {
401        menuItems = menu.getComponents();
402        refreshMenu();
403    }
404
405    public void setParent(JComponent parent) {
406        this.parent = parent;
407    }
408
409    private void refreshMenu() {
410        if ((menuItems != null) && (menuItems.length > 0)) {
411
412            int allItemsHeight = Arrays.stream(menuItems).mapToInt(item -> item.getPreferredSize().height).sum();
413            int allowedHeight = getMaxDimensionOnScreen(parent, menu).height - parent.getInsets().top;
414            boolean mustScroll = allItemsHeight > allowedHeight;
415
416            if (mustScroll) {
417                firstIndex = Math.min(menuItems.length-1, Math.max(topFixedCount, firstIndex));
418                int scrollCount = computeScrollCount(firstIndex);
419                firstIndex = Math.min(menuItems.length - scrollCount, firstIndex);
420
421                upItem.setEnabled(firstIndex > topFixedCount);
422                downItem.setEnabled((firstIndex + scrollCount) < menuItems.length);
423
424                menu.removeAll();
425                for (int i = 0; i < topFixedCount; i++) {
426                    menu.add(menuItems[i]);
427                }
428                if (topFixedCount > 0) {
429                    menu.addSeparator();
430                }
431
432                menu.add(upItem);
433                for (int i = firstIndex; i < (scrollCount + firstIndex); i++) {
434                    menu.add(menuItems[i]);
435                }
436                menu.add(downItem);
437
438                int preferredWidth = 0;
439                for (Component item : menuItems) {
440                    preferredWidth = Math.max(preferredWidth, item.getPreferredSize().width);
441                }
442                menu.setPreferredSize(new Dimension(preferredWidth, menu.getPreferredSize().height));
443
444            } else if (!Arrays.equals(menu.getComponents(), menuItems)) {
445                // Scroll is not needed but menu is not up to date
446                menu.removeAll();
447                for (Component item : menuItems) {
448                    menu.add(item);
449                }
450            }
451
452            menu.revalidate();
453            menu.repaint();
454        }
455    }
456
457    private class MenuScrollListener implements PopupMenuListener {
458
459        @Override public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
460            setMenuItems();
461        }
462
463        @Override public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
464            // this does the menu.removeAll() that makes it possible to reuse the scroller buttons.
465            restoreMenuItems();
466            //setMenuItems();
467        }
468
469
470        @Override public void popupMenuCanceled(PopupMenuEvent e) {
471            //restoreMenuItems();
472            setMenuItems();
473        }
474
475        private void setMenuItems() {
476            menuItems = menu.getComponents();
477            refreshMenu();
478        }
479
480        private void restoreMenuItems() {
481            menu.removeAll();
482            for (Component component : menuItems) {
483                menu.add(component);
484            }
485        }
486    }
487
488    private class MenuScrollTimer extends Timer {
489        public MenuScrollTimer(final int increment, int interval) {
490            super(interval, e -> {
491                firstIndex += increment;
492                refreshMenu();
493            });
494        }
495    }
496
497    private class MenuScrollItem extends JMenuItem
498        implements ChangeListener {
499
500        private MenuScrollTimer timer;
501
502        public MenuScrollItem(MenuIcon icon, int increment) {
503            setIcon(icon);
504            setDisabledIcon(icon);
505            timer = new MenuScrollTimer(increment, interval);
506            addChangeListener(this);
507        }
508
509        public void setInterval(int interval) {
510            timer.setDelay(interval);
511        }
512
513        @Override
514        public void stateChanged(ChangeEvent e) {
515            if (isArmed() && !timer.isRunning()) {
516                timer.start();
517            }
518            if (!isArmed() && timer.isRunning()) {
519                timer.stop();
520            }
521        }
522    }
523
524    private static enum MenuIcon implements Icon {
525
526        UP(9, 1, 9),
527        DOWN(1, 9, 1);
528        static final int[] XPOINTS = {1, 5, 9};
529        final int[] yPoints;
530
531        MenuIcon(int... yPoints) {
532            this.yPoints = yPoints;
533        }
534
535        @Override public void paintIcon(Component c, Graphics g, int x, int y) {
536            Dimension size = c.getSize();
537            Graphics g2 = g.create((size.width / 2) - 5, (size.height / 2) - 5, 10, 10);
538            g2.setColor(Color.GRAY);
539            g2.drawPolygon(XPOINTS, yPoints, 3);
540            if (c.isEnabled()) {
541                g2.setColor(Color.BLACK);
542                g2.fillPolygon(XPOINTS, yPoints, 3);
543            }
544            g2.dispose();
545        }
546
547        @Override public int getIconWidth() {
548            return 0;
549        }
550
551        @Override public int getIconHeight() {
552            return ARROW_ICON_HEIGHT;
553        }
554    }
555
556    private class MouseScrollListener implements MouseWheelListener {
557        @Override public void mouseWheelMoved(MouseWheelEvent mwe) {
558            firstIndex += mwe.getWheelRotation();
559            refreshMenu();
560            mwe.consume();
561        }
562    }
563}