001/* 002 * This file is part of McIDAS-V 003 * 004 * Copyright 2007-2025 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 => 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}