Changeset 19643 in josm
- Timestamp:
- 2026-10-06T22:46:46+02:00 (3 days ago)
- Location:
- trunk
- Files:
-
- 2 added
- 3 edited
-
src/org/openstreetmap/josm/gui/Notification.java (modified) (4 diffs)
-
src/org/openstreetmap/josm/gui/NotificationManager.java (modified) (19 diffs)
-
src/org/openstreetmap/josm/tools/ImageProvider.java (modified) (1 diff)
-
test/unit/org/openstreetmap/josm/gui/NotificationManagerTest.java (added)
-
test/unit/org/openstreetmap/josm/gui/NotificationTest.java (added)
Legend:
- Unmodified
- Added
- Removed
-
trunk/src/org/openstreetmap/josm/gui/Notification.java
r17628 r19643 7 7 8 8 import javax.swing.Icon; 9 import javax.swing.ImageIcon; 9 10 import javax.swing.JEditorPane; 10 11 import javax.swing.JOptionPane; … … 14 15 import org.openstreetmap.josm.gui.widgets.JMultilineLabel; 15 16 import org.openstreetmap.josm.spi.preferences.Config; 17 import org.openstreetmap.josm.tools.ImageProvider.ImageSizes; 18 import org.openstreetmap.josm.tools.ImageResource; 16 19 17 20 /** … … 130 133 /** 131 134 * Set an icon to display on the left part of the message window. 135 * <p> 136 * Icons larger than {@link ImageSizes#NOTIFICATION} are scaled down. 132 137 * 133 138 * @param icon the icon (null means no icon is displayed) … … 135 140 */ 136 141 public Notification setIcon(Icon icon) { 137 this.icon = icon; 142 if (icon instanceof ImageIcon && (icon.getIconWidth() > ImageSizes.NOTIFICATION.getAdjustedWidth() 143 || icon.getIconHeight() > ImageSizes.NOTIFICATION.getAdjustedHeight())) { 144 this.icon = new ImageResource(((ImageIcon) icon).getImage()).getImageIconBounded(ImageSizes.NOTIFICATION.getImageDimension()); 145 } else { 146 this.icon = icon; 147 } 138 148 return this; 139 149 } -
trunk/src/org/openstreetmap/josm/gui/NotificationManager.java
r19050 r19643 5 5 6 6 import java.awt.BasicStroke; 7 import java.awt.BorderLayout; 7 8 import java.awt.Color; 8 9 import java.awt.Component; … … 13 14 import java.awt.Insets; 14 15 import java.awt.Point; 16 import java.awt.Rectangle; 15 17 import java.awt.RenderingHints; 16 18 import java.awt.Shape; 17 19 import java.awt.event.ActionEvent; 18 20 import java.awt.event.ActionListener; 21 import java.awt.event.ComponentAdapter; 22 import java.awt.event.ComponentEvent; 23 import java.awt.event.ComponentListener; 24 import java.awt.event.HierarchyEvent; 25 import java.awt.event.HierarchyListener; 19 26 import java.awt.event.MouseAdapter; 20 27 import java.awt.event.MouseEvent; … … 26 33 27 34 import javax.swing.AbstractAction; 28 import javax.swing.BorderFactory;29 35 import javax.swing.GroupLayout; 30 36 import javax.swing.JButton; … … 41 47 import org.openstreetmap.josm.gui.help.HelpUtil; 42 48 import org.openstreetmap.josm.gui.util.GuiHelper; 49 import org.openstreetmap.josm.tools.GuiSizesHelper; 43 50 import org.openstreetmap.josm.tools.ImageProvider; 44 51 import org.openstreetmap.josm.tools.Logging; … … 46 53 /** 47 54 * Manages {@link Notification}s, i.e. displays them on screen. 48 * 55 * <p> 49 56 * Don't use this class directly, but use {@link Notification#show()}. 50 * 57 * <p> 51 58 * If multiple messages are sent in a short period of time, they are put in 52 59 * a queue and displayed one after the other. 53 * 60 * <p> 54 61 * The user can stop the timer (freeze the message) by moving the mouse cursor 55 62 * above the panel. As a visual cue, the background color changes from … … 65 72 private Notification currentNotification; 66 73 private NotificationPanel currentNotificationPanel; 74 75 /** the component {@link #currentNotificationPanel} is aligned to, {@code null} while nothing is displayed */ 76 private Component notificationAnchor; 77 78 /** keeps the displayed notification aligned when the layout around it changes, e.g. in fullscreen mode */ 79 private final ComponentListener anchorListener = new ComponentAdapter() { 80 @Override 81 public void componentResized(ComponentEvent e) { 82 updateNotificationPosition(); 83 } 84 85 @Override 86 public void componentMoved(ComponentEvent e) { 87 updateNotificationPosition(); 88 } 89 }; 90 91 /** realigns the displayed notification when toggling fullscreen, before the window is painted with it at its old position */ 92 private final HierarchyListener showingListener = e -> { 93 if ((e.getChangeFlags() & HierarchyEvent.SHOWING_CHANGED) != 0 && e.getComponent().isShowing()) { 94 updateNotificationPosition(); 95 } 96 }; 97 98 /** brings the displayed notification over to the map view as soon as one is opened, and back when it is closed */ 99 private final MapFrameListener mapFrameListener = (oldFrame, newFrame) -> updateNotificationPosition(); 100 67 101 private final Deque<Notification> queue; 68 102 … … 74 108 private static NotificationManager instance; 75 109 110 /** margin between the notification panel and the borders of its anchor, in unscaled pixels */ 111 private static final int MARGIN = 10; 112 76 113 private static final Color PANEL_SEMITRANSPARENT = new Color(224, 236, 249, 230); 77 114 private static final Color PANEL_OPAQUE = new Color(224, 236, 249); … … 79 116 NotificationManager() { 80 117 queue = new LinkedList<>(); 81 hideTimer = new Timer(Notification.TIME_DEFAULT, e -> this.stopHideTimer()); 118 hideTimer = new Timer(Notification.TIME_DEFAULT, e -> this.stopHideTimer(null)); 82 119 hideTimer.setRepeats(false); 83 120 pauseTimer = new Timer(pauseTime.get(), new PauseFinishedEvent()); … … 88 125 89 126 /** 90 * Show the given notification (unless a duplicate notification is being shown at the moment or at the end of the queue)127 * Show the given notification (unless a duplicate notification is being shown at the moment or is already queued) 91 128 * @param note The note to show. 92 129 * @see Notification#show() … … 94 131 void showNotification(Notification note) { 95 132 synchronized (queue) { 96 if (Objects.equals(note, currentNotification) || Objects.equals(note, queue.peekLast())) {133 if (Objects.equals(note, currentNotification) || queue.contains(note)) { 97 134 Logging.debug("Dropping duplicate notification {0}", note); 98 135 return; 99 136 } 100 137 queue.add(note); 101 processQueue(); 102 } 138 } 139 // must not run while the monitor is held, see processQueue() 140 processQueue(); 103 141 } 104 142 … … 109 147 */ 110 148 void replaceExistingNotification(Notification oldNotification, Notification newNotification) { 149 boolean isDisplayed; 111 150 synchronized (queue) { 112 if (Objects.equals(oldNotification, currentNotification)) { 113 stopHideTimer(); 114 } else { 151 isDisplayed = Objects.equals(oldNotification, currentNotification); 152 if (!isDisplayed) { 115 153 queue.remove(oldNotification); 116 154 } 117 showNotification(newNotification); 118 processQueue(); 119 } 120 } 121 155 } 156 if (isDisplayed) { 157 // must not run while the monitor is held either, it waits for the EDT as well 158 stopHideTimer(oldNotification); 159 } 160 showNotification(newNotification); 161 } 162 163 /** 164 * Displays the next queued notification, unless one is being displayed already or the queue is empty. 165 * <p> 166 * Only the state transition is guarded by the monitor of {@link #queue}. The rest waits for the EDT, and the EDT 167 * takes that very monitor as well, so holding it any longer would deadlock every caller 168 * that is not the EDT itself. 169 */ 122 170 private void processQueue() { 123 if (running) return; 124 125 currentNotification = queue.poll(); 126 if (currentNotification == null) return; 171 synchronized (queue) { 172 if (running) return; 173 174 currentNotification = queue.poll(); 175 if (currentNotification == null) return; 176 177 // claim the slot before releasing the monitor, so that no concurrent call displays a second notification 178 running = true; 179 } 127 180 128 181 GuiHelper.runInEDTAndWait(() -> { 129 currentNotificationPanel = new NotificationPanel(currentNotification, new FreezeMouseListener(), e -> this.stopHideTimer()); 182 currentNotificationPanel = new NotificationPanel(currentNotification, new FreezeMouseListener(), e -> this.stopHideTimer(null)); 183 currentNotificationPanel.addHierarchyListener(showingListener); 130 184 currentNotificationPanel.validate(); 131 185 132 int margin = 5; 186 currentNotificationPanel.setSize(currentNotificationPanel.getPreferredSize()); 187 133 188 JFrame parentWindow = MainApplication.getMainFrame(); 134 Dimension size = currentNotificationPanel.getPreferredSize();135 189 if (parentWindow != null) { 136 int x;137 int y;138 MapFrame map = MainApplication.getMap();139 if (MainApplication.isDisplayingMapView() && map.mapView.getHeight() > 0) {140 MapView mv = map.mapView;141 Point mapViewPos = SwingUtilities.convertPoint(mv.getParent(), mv.getX(), mv.getY(), MainApplication.getMainFrame());142 x = mapViewPos.x + margin;143 y = mapViewPos.y + mv.getHeight() - map.statusLine.getHeight() - size.height - margin;144 } else {145 x = margin;146 y = parentWindow.getHeight() - MainApplication.getToolbar().control.getSize().height - size.height - margin;147 }148 190 parentWindow.getLayeredPane().add(currentNotificationPanel, JLayeredPane.POPUP_LAYER, 0); 149 150 currentNotificationPanel.setLocation(x, y); 151 } 152 currentNotificationPanel.setSize(size); 191 MainApplication.addMapFrameListener(mapFrameListener); 192 updateNotificationPosition(); 193 } 153 194 currentNotificationPanel.setVisible(true); 154 195 }); 155 196 156 running = true;157 197 elapsedTime = 0; 158 159 198 startHideTimer(); 199 } 200 201 /** 202 * Aligns the displayed notification to the map view, or to the content pane while no map view is displayed, 203 * and keeps listening to that anchor for as long as the notification is displayed. 204 */ 205 private void updateNotificationPosition() { 206 JFrame parentWindow = MainApplication.getMainFrame(); 207 if (currentNotificationPanel == null || parentWindow == null) { 208 return; 209 } 210 Component anchor = MainApplication.isDisplayingMapView() ? MainApplication.getMap().mapView : parentWindow.getContentPane(); 211 if (anchor != notificationAnchor) { 212 // the map view is created and destroyed along with the layers, so the anchor may change while displaying 213 detachAnchorListener(); 214 notificationAnchor = anchor; 215 anchor.addComponentListener(anchorListener); 216 } 217 // a map view that has just been created is not laid out yet; its first resize event brings the notification over 218 Component target = anchor.getHeight() > 0 ? anchor : parentWindow.getContentPane(); 219 currentNotificationPanel.setLocation(getNotificationPosition(target, parentWindow.getLayeredPane(), 220 currentNotificationPanel.getSize(), GuiSizesHelper.getSizeDpiAdjusted(MARGIN))); 221 } 222 223 /** 224 * Stops listening to the anchor of the notification that is no longer displayed. 225 */ 226 private void detachAnchorListener() { 227 if (notificationAnchor != null) { 228 notificationAnchor.removeComponentListener(anchorListener); 229 notificationAnchor = null; 230 } 231 } 232 233 /** 234 * Computes the position of a notification panel in the coordinate system of {@code container}, which unlike 235 * the one of the main window does not depend on the window decorations, i.e. on fullscreen mode. 236 * <p> 237 * The panel is aligned to the bottom left corner of {@code anchor}, or to its top left corner if it is taller, 238 * so that the beginning of a long message stays readable. 239 * 240 * @param anchor the component the notification is aligned to, e.g. the map view 241 * @param container the container the notification panel is added to 242 * @param size the size of the notification panel 243 * @param margin the margin to keep between the notification panel and the borders of {@code anchor} 244 * @return the location of the upper left corner of the notification panel 245 */ 246 static Point getNotificationPosition(Component anchor, Container container, Dimension size, int margin) { 247 Rectangle bounds = SwingUtilities.convertRectangle(anchor.getParent(), anchor.getBounds(), container); 248 int y = Math.max(bounds.y + margin, bounds.y + bounds.height - size.height - margin); 249 return new Point(bounds.x + margin, y); 160 250 } 161 251 … … 170 260 } 171 261 172 private void stopHideTimer() { 173 hideTimer.stop(); 174 if (currentNotificationPanel != null) { 262 /** 263 * Hides the displayed notification and starts the pause before the next one. 264 * 265 * @param expected the notification to hide, or {@code null} for whichever is displayed. Nothing happens if 266 * another notification is displayed by now. 267 */ 268 private void stopHideTimer(Notification expected) { 269 // may be reached from any thread through replaceExistingNotification() 270 GuiHelper.runInEDTAndWait(() -> { 271 if (currentNotificationPanel == null || (expected != null && !Objects.equals(expected, currentNotification))) { 272 return; 273 } 274 hideTimer.stop(); 275 detachAnchorListener(); 276 MainApplication.removeMapFrameListener(mapFrameListener); 175 277 currentNotificationPanel.setVisible(false); 176 278 JFrame parent = MainApplication.getMainFrame(); … … 179 281 } 180 282 currentNotificationPanel = null; 181 } 182 pauseTimer.restart(); 283 synchronized (queue) { 284 // forget it, or an identical notification shown during the pause is dropped as a duplicate 285 currentNotification = null; 286 } 287 pauseTimer.restart(); 288 }); 183 289 } 184 290 … … 189 295 synchronized (queue) { 190 296 running = false; 191 processQueue();192 }297 } 298 processQueue(); 193 299 } 194 300 } … … 198 304 @Override 199 305 public void actionPerformed(ActionEvent e) { 306 // AWT still delivers the mouse exit event of a panel that has just been removed 200 307 if (currentNotificationPanel != null) { 201 308 currentNotificationPanel.setNotificationBackground(PANEL_SEMITRANSPARENT); 202 309 currentNotificationPanel.repaint(); 203 }204 startHideTimer();310 startHideTimer(); 311 } 205 312 } 206 313 } … … 236 343 237 344 private void build(final Notification note, MouseListener freeze, ActionListener hideListener) { 345 // the default FlowLayout would add gaps around the visible notification 346 setLayout(new BorderLayout()); 238 347 JButton btnClose = new JButton(); 239 348 btnClose.addActionListener(hideListener); 240 349 btnClose.setIcon(ImageProvider.get("misc", "grey_x")); 241 btnClose.setPreferredSize(new Dimension(50, 50)); 350 btnClose.setPreferredSize(GuiSizesHelper.getDimensionDpiAdjusted(new Dimension(50, 50))); 242 351 btnClose.setMargin(new Insets(0, 0, 1, 1)); 243 352 btnClose.setContentAreaFilled(false); … … 271 380 layout.setAutoCreateContainerGaps(true); 272 381 273 innerPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5)); 274 add(innerPanel); 382 add(innerPanel, BorderLayout.CENTER); 275 383 276 384 JLabel icon = null; … … 342 450 if (unfreezeDelayTimer.isRunning()) { 343 451 unfreezeDelayTimer.stop(); 344 } else { 452 } else if (currentNotificationPanel != null) { 453 // AWT still delivers events for a panel that has just been removed 345 454 hideTimer.stop(); 346 455 elapsedTime += System.currentTimeMillis() - displayTimeStart; … … 368 477 @Override 369 478 protected void paintComponent(Graphics graphics) { 370 Graphics2D g = (Graphics2D) graphics; 371 g.setRenderingHint( 372 RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); 373 g.setColor(getBackground()); 374 float lineWidth = 1.4f; 375 Shape rect = new RoundRectangle2D.Double( 376 lineWidth/2d + getInsets().left, 377 lineWidth/2d + getInsets().top, 378 getWidth() - lineWidth/2d - getInsets().left - getInsets().right, 379 getHeight() - lineWidth/2d - getInsets().top - getInsets().bottom, 380 20, 20); 381 382 g.fill(rect); 383 g.setColor(getForeground()); 384 g.setStroke(new BasicStroke(lineWidth)); 385 g.draw(rect); 479 // paint on a copy, so that neither the antialiasing hint nor the stroke leak into the given context 480 Graphics2D g = (Graphics2D) graphics.create(); 481 try { 482 g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); 483 g.setColor(getBackground()); 484 float lineWidth = 1.4f; 485 // the outline goes on the content box, not around the empty border 486 Insets insets = getInsets(); 487 Shape rect = new RoundRectangle2D.Double( 488 insets.left + lineWidth/2d, 489 insets.top + lineWidth/2d, 490 getWidth() - insets.left - insets.right - lineWidth, 491 getHeight() - insets.top - insets.bottom - lineWidth, 492 20, 20); 493 494 g.fill(rect); 495 g.setColor(getForeground()); 496 g.setStroke(new BasicStroke(lineWidth)); 497 g.draw(rect); 498 } finally { 499 g.dispose(); 500 } 386 501 super.paintComponent(graphics); 387 502 } -
trunk/src/org/openstreetmap/josm/tools/ImageProvider.java
r19641 r19643 183 183 * @since 16872 184 184 */ 185 HTMLINLINE(24, 24); 185 HTMLINLINE(24, 24), 186 /** 187 * Notification icon maximum size 188 * @since 19643 189 */ 190 NOTIFICATION(Config.getPref().getInt("iconsize.notification", 32)); 186 191 187 192 private final int virtualWidth;
Note:
See TracChangeset
for help on using the changeset viewer.
