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 final MenuScrollItem upItem; 046 private final 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 = Arrays.stream(menuItems).mapToInt(item -> item.getPreferredSize().height).sum(); 309 int allowedHeight = WindowGeometry.getMaxDimensionOnScreen(menu).height - MainApplication.getMainFrame().getInsets().top; 310 boolean mustScroll = allItemsHeight > allowedHeight; 311 if (mustScroll) { 312 firstIndex = Math.min(menuItems.length-1, Math.max(topFixedCount, firstIndex)); 313 int scrollCount = computeScrollCount(firstIndex); 314 firstIndex = Math.min(menuItems.length - scrollCount, firstIndex); 315 316 upItem.setEnabled(firstIndex > topFixedCount); 317 downItem.setEnabled(firstIndex + scrollCount < menuItems.length); 318 319 menu.removeAll(); 320 for (int i = 0; i < topFixedCount; i++) { 321 menu.add(menuItems[i]); 322 } 323 if (topFixedCount > 0) { 324 menu.addSeparator(); 325 } 326 327 menu.add(upItem); 328 for (int i = firstIndex; i < scrollCount + firstIndex; i++) { 329 menu.add(menuItems[i]); 330 } 331 menu.add(downItem); 332 333 int preferredWidth = 0; 334 for (Component item : menuItems) { 335 preferredWidth = Math.max(preferredWidth, item.getPreferredSize().width); 336 } 337 menu.setPreferredSize(new Dimension(preferredWidth, menu.getPreferredSize().height)); 338 339 } else if (!Arrays.equals(menu.getComponents(), menuItems)) { 340 // Scroll is not needed but menu is not up to date 341 menu.removeAll(); 342 for (Component item : menuItems) { 343 menu.add(item); 344 } 345 } 346 347 menu.revalidate(); 348 menu.repaint(); 349 } 350 } 351 352 private class MenuScrollListener implements PopupMenuListener { 353 354 @Override 355 public void popupMenuWillBecomeVisible(PopupMenuEvent e) { 356 setMenuItems(); 357 } 358 359 @Override 360 public void popupMenuWillBecomeInvisible(PopupMenuEvent e) { 361 restoreMenuItems(); 362 } 363 364 @Override 365 public void popupMenuCanceled(PopupMenuEvent e) { 366 restoreMenuItems(); 367 } 368 369 private void setMenuItems() { 370 menuItems = menu.getComponents(); 371 refreshMenu(); 372 } 373 374 private void restoreMenuItems() { 375 menu.removeAll(); 376 for (Component component : menuItems) { 377 menu.add(component); 378 } 379 } 380 } 381 382 private class MenuScrollTimer extends Timer { 383 384 MenuScrollTimer(final int increment, int interval) { 385 super(interval, new ActionListener() { 386 387 @Override 388 public void actionPerformed(ActionEvent e) { 389 firstIndex += increment; 390 refreshMenu(); 391 } 392 }); 393 } 394 } 395 396 private class MenuScrollItem extends JMenuItem 397 implements ChangeListener { 398 399 private final MenuScrollTimer timer; 400 401 MenuScrollItem(MenuIcon icon, int increment, int interval) { 402 setIcon(icon); 403 setDisabledIcon(icon); 404 timer = new MenuScrollTimer(increment, interval); 405 addChangeListener(this); 406 } 407 408 @Override 409 public void stateChanged(ChangeEvent e) { 410 if (isArmed() && !timer.isRunning()) { 411 timer.start(); 412 } 413 if (!isArmed() && timer.isRunning()) { 414 timer.stop(); 415 } 416 } 417 } 418 419 private enum MenuIcon implements Icon { 420 421 UP(9, 1, 9), 422 DOWN(1, 9, 1); 423 private static final int[] XPOINTS = {1, 5, 9}; 424 @SuppressWarnings("ImmutableEnumChecker") 425 private final int[] yPoints; 426 427 MenuIcon(int... yPoints) { 428 this.yPoints = yPoints; 429 } 430 431 @Override 432 public void paintIcon(Component c, Graphics g, int x, int y) { 433 Dimension size = c.getSize(); 434 Graphics g2 = g.create(size.width / 2 - 5, size.height / 2 - 5, 10, 10); 435 g2.setColor(Color.GRAY); 436 g2.drawPolygon(XPOINTS, yPoints, 3); 437 if (c.isEnabled()) { 438 g2.setColor(Color.BLACK); 439 g2.fillPolygon(XPOINTS, yPoints, 3); 440 } 441 g2.dispose(); 442 } 443 444 @Override 445 public int getIconWidth() { 446 return 0; 447 } 448 449 @Override 450 public int getIconHeight() { 451 return ARROW_ICON_HEIGHT; 452 } 453 } 454 455 private class MouseScrollListener implements MouseWheelListener { 456 @Override 457 public void mouseWheelMoved(MouseWheelEvent mwe) { 458 firstIndex += mwe.getWheelRotation(); 459 refreshMenu(); 460 if (Logging.isDebugEnabled()) { 461 Logging.debug("{0} consuming event {1}", getClass().getName(), mwe); 462 } 463 mwe.consume(); 464 } 465 } 466}