Ticket #23002: 23002.patch
| File 23002.patch, 38.2 KB (added by , 39 hours ago) |
|---|
-
src/org/openstreetmap/josm/gui/NotificationManager.java
Subject: [PATCH] see #23002 - paint the notification inside its border again see #23002 - reword the notification manager test see #23002 - forget the notification that has just been hidden see #23002 - hide the right notification, and follow the map view when it opens see #23002 - fix deadlock, exceptions and rendering quirks around notifications see #23002 - keep displayed notifications aligned to their anchor fix #23002 - inconsistent notification position --- IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 diff --git a/src/org/openstreetmap/josm/gui/NotificationManager.java b/src/org/openstreetmap/josm/gui/NotificationManager.java
a b 4 4 import static org.openstreetmap.josm.tools.I18n.tr; 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; 9 10 import java.awt.Container; … … 12 13 import java.awt.Graphics2D; 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; 19 24 import java.awt.event.MouseAdapter; 20 25 import java.awt.event.MouseEvent; 21 26 import java.awt.event.MouseListener; … … 40 45 import org.openstreetmap.josm.gui.help.HelpBrowser; 41 46 import org.openstreetmap.josm.gui.help.HelpUtil; 42 47 import org.openstreetmap.josm.gui.util.GuiHelper; 48 import org.openstreetmap.josm.tools.GuiSizesHelper; 43 49 import org.openstreetmap.josm.tools.ImageProvider; 44 50 import org.openstreetmap.josm.tools.Logging; 45 51 46 52 /** 47 53 * Manages {@link Notification}s, i.e. displays them on screen. 48 * 54 * <p> 49 55 * Don't use this class directly, but use {@link Notification#show()}. 50 * 56 * <p> 51 57 * If multiple messages are sent in a short period of time, they are put in 52 58 * a queue and displayed one after the other. 53 * 59 * <p> 54 60 * The user can stop the timer (freeze the message) by moving the mouse cursor 55 61 * above the panel. As a visual cue, the background color changes from 56 62 * semi-transparent to opaque while the timer is frozen. … … 64 70 65 71 private Notification currentNotification; 66 72 private NotificationPanel currentNotificationPanel; 73 /** the component {@link #currentNotificationPanel} is aligned to, {@code null} while nothing is displayed */ 74 private Component notificationAnchor; 75 /** keeps the displayed notification aligned when the layout around it changes, e.g. in fullscreen mode */ 76 private final ComponentListener anchorListener = new ComponentAdapter() { 77 @Override 78 public void componentResized(ComponentEvent e) { 79 updateNotificationPosition(); 80 } 81 82 @Override 83 public void componentMoved(ComponentEvent e) { 84 updateNotificationPosition(); 85 } 86 }; 87 /** brings the displayed notification over to the map view as soon as one is opened, and back when it is closed */ 88 private final MapFrameListener mapFrameListener = (oldFrame, newFrame) -> updateNotificationPosition(); 67 89 private final Deque<Notification> queue; 68 90 69 91 private static final IntegerProperty pauseTime = new IntegerProperty("notification-default-pause-time-ms", 300); // milliseconds … … 73 95 74 96 private static NotificationManager instance; 75 97 98 /** margin between the notification panel and the borders of its anchor, in unscaled pixels */ 99 private static final int MARGIN = 10; 100 76 101 private static final Color PANEL_SEMITRANSPARENT = new Color(224, 236, 249, 230); 77 102 private static final Color PANEL_OPAQUE = new Color(224, 236, 249); 78 103 79 104 NotificationManager() { 80 105 queue = new LinkedList<>(); 81 hideTimer = new Timer(Notification.TIME_DEFAULT, e -> this.stopHideTimer( ));106 hideTimer = new Timer(Notification.TIME_DEFAULT, e -> this.stopHideTimer(null)); 82 107 hideTimer.setRepeats(false); 83 108 pauseTimer = new Timer(pauseTime.get(), new PauseFinishedEvent()); 84 109 pauseTimer.setRepeats(false); … … 87 112 } 88 113 89 114 /** 90 * Show the given notification (unless a duplicate notification is being shown at the moment or at the end of the queue)115 * Show the given notification (unless a duplicate notification is being shown at the moment or is already queued) 91 116 * @param note The note to show. 92 117 * @see Notification#show() 93 118 */ 94 119 void showNotification(Notification note) { 95 120 synchronized (queue) { 96 if (Objects.equals(note, currentNotification) || Objects.equals(note, queue.peekLast())) {121 if (Objects.equals(note, currentNotification) || queue.contains(note)) { 97 122 Logging.debug("Dropping duplicate notification {0}", note); 98 123 return; 99 124 } 100 125 queue.add(note); 101 processQueue(); 102 } 126 } 127 // must not run while the monitor is held, see processQueue() 128 processQueue(); 103 129 } 104 130 105 131 /** … … 108 134 * @param newNotification the notification to show 109 135 */ 110 136 void replaceExistingNotification(Notification oldNotification, Notification newNotification) { 137 boolean isDisplayed; 111 138 synchronized (queue) { 112 if (Objects.equals(oldNotification, currentNotification)) { 113 stopHideTimer(); 114 } else { 139 isDisplayed = Objects.equals(oldNotification, currentNotification); 140 if (!isDisplayed) { 115 141 queue.remove(oldNotification); 116 142 } 117 showNotification(newNotification); 118 processQueue(); 119 } 143 } 144 if (isDisplayed) { 145 // must not run while the monitor is held either, it waits for the EDT as well 146 stopHideTimer(oldNotification); 147 } 148 // processes the queue itself 149 showNotification(newNotification); 120 150 } 121 151 152 /** 153 * Displays the next queued notification, unless one is being displayed already or the queue is empty. 154 * <p> 155 * Only the state transition is guarded by the monitor of {@link #queue}. The rest waits for the EDT, and the EDT 156 * takes that very monitor in {@link PauseFinishedEvent}, so holding it any longer would deadlock every caller 157 * that is not the EDT itself. 158 */ 122 159 private void processQueue() { 123 if (running) return; 160 synchronized (queue) { 161 if (running) return; 124 162 125 currentNotification = queue.poll();126 if (currentNotification == null) return;163 currentNotification = queue.poll(); 164 if (currentNotification == null) return; 127 165 166 // claim the slot before releasing the monitor, so that no concurrent call displays a second notification 167 running = true; 168 } 169 128 170 GuiHelper.runInEDTAndWait(() -> { 129 currentNotificationPanel = new NotificationPanel(currentNotification, new FreezeMouseListener(), e -> this.stopHideTimer( ));171 currentNotificationPanel = new NotificationPanel(currentNotification, new FreezeMouseListener(), e -> this.stopHideTimer(null)); 130 172 currentNotificationPanel.validate(); 131 173 132 int margin = 5; 174 currentNotificationPanel.setSize(currentNotificationPanel.getPreferredSize()); 175 133 176 JFrame parentWindow = MainApplication.getMainFrame(); 134 Dimension size = currentNotificationPanel.getPreferredSize();135 177 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 178 parentWindow.getLayeredPane().add(currentNotificationPanel, JLayeredPane.POPUP_LAYER, 0); 149 150 currentNotificationPanel.setLocation(x, y);179 MainApplication.addMapFrameListener(mapFrameListener); 180 updateNotificationPosition(); 151 181 } 152 currentNotificationPanel.setSize(size);153 182 currentNotificationPanel.setVisible(true); 154 183 }); 155 184 156 running = true;157 185 elapsedTime = 0; 158 159 186 startHideTimer(); 160 187 } 161 188 189 /** 190 * Aligns the currently displayed notification to its anchor: the map view, or the whole content pane while no 191 * map view is displayed. Also (re-)registers the listener that keeps it aligned for as long as it is displayed, 192 * so that the notification follows its anchor when the layout changes, e.g. when toggling fullscreen mode, the 193 * toggle dialogs panel or the status line. 194 */ 195 private void updateNotificationPosition() { 196 JFrame parentWindow = MainApplication.getMainFrame(); 197 if (currentNotificationPanel == null || parentWindow == null) { 198 return; 199 } 200 MapFrame map = MainApplication.getMap(); 201 // The notification is aligned to the map view, or to the whole content pane if there is none. 202 Component anchor = MainApplication.isDisplayingMapView() ? map.mapView : parentWindow.getContentPane(); 203 if (anchor != notificationAnchor) { 204 // the map view is created and destroyed along with the layers, so the anchor may change while displaying 205 detachAnchorListener(); 206 notificationAnchor = anchor; 207 anchor.addComponentListener(anchorListener); 208 } 209 // a map view that has just been created is not laid out yet; its first resize event brings the notification over 210 Component target = anchor.getHeight() > 0 ? anchor : parentWindow.getContentPane(); 211 currentNotificationPanel.setLocation(getNotificationPosition(target, parentWindow.getLayeredPane(), 212 currentNotificationPanel.getSize(), GuiSizesHelper.getSizeDpiAdjusted(MARGIN))); 213 } 214 215 /** 216 * Stops listening to the anchor of the notification that is no longer displayed. 217 */ 218 private void detachAnchorListener() { 219 if (notificationAnchor != null) { 220 notificationAnchor.removeComponentListener(anchorListener); 221 notificationAnchor = null; 222 } 223 } 224 225 /** 226 * Computes the position of a notification panel, expressed in the coordinate system of {@code container}. 227 * <p> 228 * The panel is aligned to the bottom left corner of {@code anchor}. The position has to be computed in the 229 * coordinate system of the container the panel is added to, and not in the one of the main window: the latter 230 * is additionally offset by the window decoration insets, which are only present when not in fullscreen mode. 231 * <p> 232 * A panel taller than its anchor is aligned to the top left corner instead, so that the beginning of a long 233 * message stays readable rather than being cut off by the upper border of the window. 234 * 235 * @param anchor the component the notification is aligned to, e.g. the map view 236 * @param container the container the notification panel is added to 237 * @param size the size of the notification panel 238 * @param margin the margin to keep between the notification panel and the borders of {@code anchor} 239 * @return the location of the upper left corner of the notification panel 240 */ 241 static Point getNotificationPosition(Component anchor, Container container, Dimension size, int margin) { 242 Rectangle bounds = SwingUtilities.convertRectangle(anchor.getParent(), anchor.getBounds(), container); 243 int y = Math.max(bounds.y + margin, bounds.y + bounds.height - size.height - margin); 244 return new Point(bounds.x + margin, y); 245 } 246 162 247 private void startHideTimer() { 248 if (currentNotification == null) { 249 return; 250 } 163 251 int remaining = (int) (currentNotification.getDuration() - elapsedTime); 164 252 if (remaining < 300) { 165 253 remaining = 300; … … 169 257 hideTimer.restart(); 170 258 } 171 259 172 private void stopHideTimer() { 173 hideTimer.stop(); 174 if (currentNotificationPanel != null) { 260 /** 261 * Hides the displayed notification and starts the pause before the next one. 262 * 263 * @param expected the notification that is meant to be hidden, or {@code null} for whichever is displayed. 264 * Nothing happens when another notification has been promoted in the meantime, which the 265 * queue does on the EDT while a caller from another thread is on its way here. 266 */ 267 private void stopHideTimer(Notification expected) { 268 // may be reached from any thread through replaceExistingNotification() 269 boolean hidden = Boolean.TRUE.equals(GuiHelper.runInEDTAndWaitAndReturn(() -> { 270 if (currentNotificationPanel == null || (expected != null && !Objects.equals(expected, currentNotification))) { 271 return Boolean.FALSE; 272 } 273 hideTimer.stop(); 274 detachAnchorListener(); 275 MainApplication.removeMapFrameListener(mapFrameListener); 175 276 currentNotificationPanel.setVisible(false); 176 277 JFrame parent = MainApplication.getMainFrame(); 177 278 if (parent != null) { 178 279 parent.getLayeredPane().remove(currentNotificationPanel); 179 280 } 180 281 currentNotificationPanel = null; 181 } 182 pauseTimer.restart(); 282 return Boolean.TRUE; 283 })); 284 if (hidden) { 285 // the monitor is taken after waiting for the EDT, never around it, see processQueue(). Nothing else can 286 // set the field in between: processQueue() bails out until the pause below has set running back to false 287 synchronized (queue) { 288 // forget it, or an identical notification triggered before the pause is over counts as a duplicate 289 currentNotification = null; 290 } 291 pauseTimer.restart(); 292 } 183 293 } 184 294 185 295 private final class PauseFinishedEvent implements ActionListener { … … 188 298 public void actionPerformed(ActionEvent e) { 189 299 synchronized (queue) { 190 300 running = false; 191 processQueue();192 }301 } 302 processQueue(); 193 303 } 194 304 } 195 305 … … 197 307 198 308 @Override 199 309 public void actionPerformed(ActionEvent e) { 310 // nothing to unfreeze if the notification has been hidden in the meantime: AWT still delivers the 311 // mouse exit event of a panel that has just been removed, and restarting the hide timer for a 312 // notification that is gone only delays the next one 200 313 if (currentNotificationPanel != null) { 201 314 currentNotificationPanel.setNotificationBackground(PANEL_SEMITRANSPARENT); 202 315 currentNotificationPanel.repaint(); 203 }204 startHideTimer();316 startHideTimer(); 317 } 205 318 } 206 319 } 207 320 … … 235 348 } 236 349 237 350 private void build(final Notification note, MouseListener freeze, ActionListener hideListener) { 351 // BorderLayout lets the inner panel fill this panel, so that the visible notification is exactly 352 // where it has been positioned, without the extra gaps a FlowLayout would add around it 353 setLayout(new BorderLayout()); 238 354 JButton btnClose = new JButton(); 239 355 btnClose.addActionListener(hideListener); 240 356 btnClose.setIcon(ImageProvider.get("misc", "grey_x")); 241 btnClose.setPreferredSize( new Dimension(50, 50));357 btnClose.setPreferredSize(GuiSizesHelper.getDimensionDpiAdjusted(new Dimension(50, 50))); 242 358 btnClose.setMargin(new Insets(0, 0, 1, 1)); 243 359 btnClose.setContentAreaFilled(false); 244 360 // put it in JToolBar to get a better appearance … … 271 387 layout.setAutoCreateContainerGaps(true); 272 388 273 389 innerPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5)); 274 add(innerPanel );390 add(innerPanel, BorderLayout.CENTER); 275 391 276 392 JLabel icon = null; 277 393 if (note.getIcon() != null) { … … 341 457 public void mouseEntered(MouseEvent e) { 342 458 if (unfreezeDelayTimer.isRunning()) { 343 459 unfreezeDelayTimer.stop(); 344 } else { 460 } else if (currentNotificationPanel != null) { 461 // AWT still delivers events for a panel that has just been removed 345 462 hideTimer.stop(); 346 463 elapsedTime += System.currentTimeMillis() - displayTimeStart; 347 464 currentNotificationPanel.setNotificationBackground(PANEL_OPAQUE); … … 367 484 368 485 @Override 369 486 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); 487 // paint on a copy, so that neither the antialiasing hint nor the stroke leak into the given context 488 Graphics2D g = (Graphics2D) graphics.create(); 489 try { 490 g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); 491 g.setColor(getBackground()); 492 float lineWidth = 1.4f; 493 // the outline is the visible border of the notification, so it goes on the content box: painting it 494 // on the outer bounds instead turns the empty border of the panel into a gap around the message 495 Insets insets = getInsets(); 496 Shape rect = new RoundRectangle2D.Double( 497 insets.left + lineWidth/2d, 498 insets.top + lineWidth/2d, 499 getWidth() - insets.left - insets.right - lineWidth, 500 getHeight() - insets.top - insets.bottom - lineWidth, 501 20, 20); 381 502 382 g.fill(rect); 383 g.setColor(getForeground()); 384 g.setStroke(new BasicStroke(lineWidth)); 385 g.draw(rect); 503 g.fill(rect); 504 g.setColor(getForeground()); 505 g.setStroke(new BasicStroke(lineWidth)); 506 g.draw(rect); 507 } finally { 508 g.dispose(); 509 } 386 510 super.paintComponent(graphics); 387 511 } 388 512 } -
new file test/unit/org/openstreetmap/josm/gui/NotificationManagerTest.java
IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 diff --git a/test/unit/org/openstreetmap/josm/gui/NotificationManagerTest.java b/test/unit/org/openstreetmap/josm/gui/NotificationManagerTest.java new file mode 100644
- + 1 // License: GPL. For details, see LICENSE file. 2 package org.openstreetmap.josm.gui; 3 4 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 5 import static org.junit.jupiter.api.Assertions.assertEquals; 6 import static org.junit.jupiter.api.Assertions.assertFalse; 7 import static org.junit.jupiter.api.Assertions.assertNotNull; 8 import static org.junit.jupiter.api.Assertions.assertNull; 9 import static org.junit.jupiter.api.Assertions.assertSame; 10 import static org.junit.jupiter.api.Assertions.assertTrue; 11 12 import java.awt.Component; 13 import java.awt.Dimension; 14 import java.awt.Graphics2D; 15 import java.awt.Point; 16 import java.awt.Rectangle; 17 import java.awt.RenderingHints; 18 import java.awt.event.ActionEvent; 19 import java.awt.event.ActionListener; 20 import java.awt.event.MouseEvent; 21 import java.awt.event.MouseListener; 22 import java.awt.image.BufferedImage; 23 import java.lang.reflect.Field; 24 import java.lang.reflect.Method; 25 import java.util.Deque; 26 import java.util.concurrent.CountDownLatch; 27 import java.util.concurrent.TimeUnit; 28 import java.util.concurrent.atomic.AtomicBoolean; 29 30 import javax.swing.BorderFactory; 31 import javax.swing.JLayeredPane; 32 import javax.swing.JPanel; 33 import javax.swing.SwingUtilities; 34 import javax.swing.Timer; 35 36 import org.junit.jupiter.api.Test; 37 import org.openstreetmap.josm.testutils.annotations.BasicPreferences; 38 import org.openstreetmap.josm.testutils.annotations.JosmHome; 39 import org.openstreetmap.josm.tools.ReflectionUtils; 40 41 /** 42 * Unit tests of {@link NotificationManager} class. 43 */ 44 @BasicPreferences 45 @JosmHome 46 class NotificationManagerTest { 47 48 private static final int MARGIN = 10; 49 private static final Dimension NOTIFICATION_SIZE = new Dimension(300, 60); 50 /** how long a thread may take before it counts as stuck; generous, it only has to outlast a slow machine */ 51 private static final int TIMEOUT_SECONDS = 10; 52 53 /** 54 * Builds a hierarchy similar to the one of the main window: a layered pane holding a content pane (below the 55 * menu bar) which in turn holds the map view (right of the side toolbar, above the status line). 56 * @param layeredPane the layered pane to fill 57 * @return the map view stand-in 58 */ 59 private static JPanel buildMainWindowHierarchy(JLayeredPane layeredPane) { 60 layeredPane.setBounds(0, 0, 1000, 800); 61 62 JPanel contentPane = new JPanel(null); 63 // below the menu bar 64 contentPane.setBounds(0, 25, 1000, 775); 65 layeredPane.add(contentPane); 66 67 JPanel mapView = new JPanel(); 68 // right of the side toolbar, above the status line 69 mapView.setBounds(30, 0, 970, 740); 70 contentPane.add(mapView); 71 72 return mapView; 73 } 74 75 /** 76 * Unit test of {@link NotificationManager#getNotificationPosition} aligning to the map view. 77 */ 78 @Test 79 void testGetNotificationPositionMapView() { 80 JLayeredPane layeredPane = new JLayeredPane(); 81 JPanel mapView = buildMainWindowHierarchy(layeredPane); 82 83 assertEquals(new Point(30 + MARGIN, 25 + 740 - NOTIFICATION_SIZE.height - MARGIN), 84 NotificationManager.getNotificationPosition(mapView, layeredPane, NOTIFICATION_SIZE, MARGIN)); 85 } 86 87 /** 88 * Unit test of {@link NotificationManager#getNotificationPosition} aligning to the content pane, which is what 89 * happens while no map view is displayed. 90 */ 91 @Test 92 void testGetNotificationPositionContentPane() { 93 JLayeredPane layeredPane = new JLayeredPane(); 94 buildMainWindowHierarchy(layeredPane); 95 JPanel contentPane = (JPanel) layeredPane.getComponent(0); 96 97 assertEquals(new Point(MARGIN, 25 + 775 - NOTIFICATION_SIZE.height - MARGIN), 98 NotificationManager.getNotificationPosition(contentPane, layeredPane, NOTIFICATION_SIZE, MARGIN)); 99 } 100 101 /** 102 * Non-regression test for <a href="https://josm.openstreetmap.de/ticket/23002">#23002</a>: the position must not 103 * depend on the insets of the window decoration, which are only there when not in fullscreen mode. 104 */ 105 @Test 106 void testTicket23002() { 107 JLayeredPane layeredPane = new JLayeredPane(); 108 JPanel mapView = buildMainWindowHierarchy(layeredPane); 109 Point fullscreen = NotificationManager.getNotificationPosition(mapView, layeredPane, NOTIFICATION_SIZE, MARGIN); 110 111 // simulate the window decoration: within the frame, the layered pane is offset by the frame insets 112 JPanel frame = new JPanel(null); 113 frame.setBounds(0, 0, 1010, 840); 114 JPanel rootPane = new JPanel(null); 115 rootPane.setBounds(5, 35, 1000, 800); 116 frame.add(rootPane); 117 rootPane.add(layeredPane); 118 119 Point windowed = NotificationManager.getNotificationPosition(mapView, layeredPane, NOTIFICATION_SIZE, MARGIN); 120 assertEquals(fullscreen, windowed); 121 122 // the notification panel is added to the layered pane, so its position has to keep it inside the map view 123 Rectangle notification = new Rectangle(windowed, NOTIFICATION_SIZE); 124 Rectangle mapViewBounds = SwingUtilities.convertRectangle(mapView.getParent(), mapView.getBounds(), layeredPane); 125 assertTrue(mapViewBounds.contains(notification), notification + " is not inside the map view " + mapViewBounds); 126 } 127 128 /** 129 * Unit test of {@link NotificationManager#getNotificationPosition} with a notification taller than its anchor: 130 * it has to stay aligned to the top of the anchor, instead of starting above the upper border of the window. 131 */ 132 @Test 133 void testGetNotificationPositionTallerThanAnchor() { 134 JLayeredPane layeredPane = new JLayeredPane(); 135 JPanel mapView = buildMainWindowHierarchy(layeredPane); 136 // a long message in a small window is easily taller than the whole main window 137 Dimension size = new Dimension(480, 800); 138 139 // the top left corner of the map view, instead of a negative y above the upper border of the window 140 assertEquals(new Point(30 + MARGIN, 25 + MARGIN), 141 NotificationManager.getNotificationPosition(mapView, layeredPane, size, MARGIN)); 142 } 143 144 private static Object getFieldValue(NotificationManager manager, String name) throws ReflectiveOperationException { 145 Field field = NotificationManager.class.getDeclaredField(name); 146 ReflectionUtils.setObjectsAccessible(field); 147 return field.get(manager); 148 } 149 150 private static Method stopHideTimer() throws ReflectiveOperationException { 151 Method stopHideTimer = NotificationManager.class.getDeclaredMethod("stopHideTimer", Notification.class); 152 ReflectionUtils.setObjectsAccessible(stopHideTimer); 153 return stopHideTimer; 154 } 155 156 private static void hideCurrentNotification(NotificationManager manager) throws ReflectiveOperationException { 157 stopHideTimer().invoke(manager, (Notification) null); 158 } 159 160 /** 161 * Tries to take the monitor of {@code lock} from another thread. 162 * @param lock the object to synchronize on 163 * @return {@code false} if some other thread holds the monitor for longer than a second 164 * @throws InterruptedException if interrupted while waiting 165 */ 166 private static boolean canTakeMonitorOf(Object lock) throws InterruptedException { 167 CountDownLatch taken = new CountDownLatch(1); 168 Thread probe = new Thread(() -> { 169 synchronized (lock) { 170 taken.countDown(); 171 } 172 }, "notification-manager-test-monitor"); 173 probe.setDaemon(true); 174 probe.start(); 175 return taken.await(1, TimeUnit.SECONDS); 176 } 177 178 /** 179 * {@link NotificationManager#showNotification} must not hold the monitor of its queue while it waits for the 180 * EDT, because the EDT takes that same monitor once the pause between two notifications is over. Otherwise a 181 * notification shown from a worker thread deadlocks with it and freezes the whole user interface. 182 * <p> 183 * The deadlock itself is not reproduced here, as it would wedge the EDT for the rest of the JVM. The monitor 184 * is watched from the EDT instead, where the panel of the notification is built. 185 * @throws Exception in case of error 186 */ 187 @Test 188 void testShowNotificationFromWorkerThreadDoesNotFreezeEdt() throws Exception { 189 NotificationManager manager = new NotificationManager(); 190 Object queue = getFieldValue(manager, "queue"); 191 AtomicBoolean monitorWasHeld = new AtomicBoolean(); 192 193 // getContent() is called on the EDT, while showNotification() is still on the stack of the worker thread 194 Notification note = new Notification("shown from a worker thread") { 195 @Override 196 public Component getContent() { 197 try { 198 monitorWasHeld.set(!canTakeMonitorOf(queue)); 199 } catch (InterruptedException e) { 200 Thread.currentThread().interrupt(); 201 } 202 return super.getContent(); 203 } 204 }; 205 206 Thread worker = new Thread(() -> manager.showNotification(note), "notification-manager-test"); 207 worker.setDaemon(true); 208 worker.start(); 209 worker.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS)); 210 211 assertFalse(worker.isAlive(), "showNotification() never returned"); 212 assertFalse(monitorWasHeld.get(), 213 "showNotification() held the monitor of the queue while waiting for the EDT, which deadlocks with PauseFinishedEvent"); 214 } 215 216 /** 217 * AWT still delivers mouse events for a notification panel that has just been removed, so the listener that 218 * freezes the hide timer has to cope with a notification that is not displayed any more. 219 * @throws Exception in case of error 220 */ 221 @Test 222 void testMouseEnteredAfterNotificationHidden() throws Exception { 223 NotificationManager manager = new NotificationManager(); 224 manager.showNotification(new Notification("hover me").setDuration(Notification.TIME_LONG)); 225 226 Component panel = (Component) getFieldValue(manager, "currentNotificationPanel"); 227 assertNotNull(panel, "no notification panel was built"); 228 229 hideCurrentNotification(manager); 230 assertNull(getFieldValue(manager, "currentNotificationPanel"), "notification panel was not removed"); 231 232 MouseEvent event = new MouseEvent(panel, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(), 233 0, 1, 1, 0, false, MouseEvent.NOBUTTON); 234 for (MouseListener listener : panel.getMouseListeners()) { 235 assertDoesNotThrow(() -> listener.mouseEntered(event), "the mouse entered a panel that is no longer displayed"); 236 } 237 } 238 239 /** 240 * The mouse exit event AWT delivers for a notification panel that has just been removed must not restart the 241 * hide timer: there is nothing left to hide, and the next queued notification would only be pushed back by 242 * another pause. 243 * @throws Exception in case of error 244 */ 245 @Test 246 void testUnfreezeAfterNotificationHidden() throws Exception { 247 NotificationManager manager = new NotificationManager(); 248 manager.showNotification(new Notification("hover me").setDuration(Notification.TIME_LONG)); 249 assertNotNull(getFieldValue(manager, "currentNotificationPanel"), "no notification panel was built"); 250 251 hideCurrentNotification(manager); 252 Timer hideTimer = (Timer) getFieldValue(manager, "hideTimer"); 253 assertFalse(hideTimer.isRunning(), "the hide timer was not stopped"); 254 255 // the mouse leaves the panel that has just been removed, which fires the unfreeze event shortly after 256 Timer unfreezeDelayTimer = (Timer) getFieldValue(manager, "unfreezeDelayTimer"); 257 ActionEvent event = new ActionEvent(unfreezeDelayTimer, ActionEvent.ACTION_PERFORMED, null); 258 for (ActionListener listener : unfreezeDelayTimer.getActionListeners()) { 259 listener.actionPerformed(event); 260 } 261 262 assertFalse(hideTimer.isRunning(), "the hide timer was restarted for a notification that is not displayed"); 263 } 264 265 /** 266 * {@link NotificationManager#replaceExistingNotification} looks up whether the notification to replace is the 267 * displayed one, releases the monitor of the queue and only hides it afterwards. The queue may have moved on in 268 * between, and the unrelated notification that took the slot must not be hidden in its place. 269 * @throws Exception in case of error 270 */ 271 @Test 272 void testReplaceNotificationThatIsNoLongerDisplayed() throws Exception { 273 NotificationManager manager = new NotificationManager(); 274 manager.showNotification(new Notification("displayed")); 275 Object panel = getFieldValue(manager, "currentNotificationPanel"); 276 assertNotNull(panel, "no notification panel was built"); 277 278 stopHideTimer().invoke(manager, new Notification("replaced while the queue moved on")); 279 assertSame(panel, getFieldValue(manager, "currentNotificationPanel"), 280 "hiding a notification that is gone removed the displayed one instead"); 281 282 // the displayed notification is of course still hidden when it is the one being replaced 283 stopHideTimer().invoke(manager, getFieldValue(manager, "currentNotification")); 284 assertNull(getFieldValue(manager, "currentNotificationPanel"), "notification panel was not removed"); 285 } 286 287 /** 288 * A notification equal to one that is already waiting in the queue is dropped, wherever in the queue it sits. 289 * @throws Exception in case of error 290 */ 291 @Test 292 void testDuplicateAnywhereInQueueIsDropped() throws Exception { 293 NotificationManager manager = new NotificationManager(); 294 manager.showNotification(new Notification("displayed")); 295 manager.showNotification(new Notification("queued first")); 296 manager.showNotification(new Notification("queued second")); 297 // equal to a notification that is in the queue, but not to the last one 298 manager.showNotification(new Notification("queued first")); 299 300 Deque<?> queue = (Deque<?>) getFieldValue(manager, "queue"); 301 assertEquals(2, queue.size(), "duplicate of a queued notification was queued again: " + queue); 302 } 303 304 /** 305 * A notification only counts as a duplicate for as long as the one it equals is really displayed. The same 306 * message triggered right after the previous one disappeared has to show up again, rather than being dropped 307 * until the pause before the next notification is over. 308 * @throws Exception in case of error 309 */ 310 @Test 311 void testSameNotificationRightAfterTheLastOneDisappeared() throws Exception { 312 NotificationManager manager = new NotificationManager(); 313 manager.showNotification(new Notification("same message")); 314 assertNotNull(getFieldValue(manager, "currentNotificationPanel"), "no notification panel was built"); 315 316 hideCurrentNotification(manager); 317 assertNull(getFieldValue(manager, "currentNotification"), "the notification that disappeared is still current"); 318 319 manager.showNotification(new Notification("same message")); 320 Deque<?> queue = (Deque<?>) getFieldValue(manager, "queue"); 321 assertEquals(1, queue.size(), "a notification triggered right after an identical one disappeared was dropped"); 322 } 323 324 /** 325 * The rounded panel paints with antialiasing, but on a copy of the graphics context it is handed, so that the 326 * hint does not leak into everything painted afterwards. 327 */ 328 @Test 329 void testRoundedPanelKeepsRenderingHints() { 330 NotificationManager.RoundedPanel panel = new NotificationManager.RoundedPanel(); 331 panel.setSize(100, 50); 332 333 BufferedImage image = new BufferedImage(100, 50, BufferedImage.TYPE_INT_ARGB); 334 Graphics2D g = image.createGraphics(); 335 try { 336 g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF); 337 panel.paintComponent(g); 338 assertEquals(RenderingHints.VALUE_ANTIALIAS_OFF, g.getRenderingHint(RenderingHints.KEY_ANTIALIASING), 339 "the notification panel changed the antialiasing hint of its caller"); 340 } finally { 341 g.dispose(); 342 } 343 } 344 345 /** 346 * The rounded outline is the visible border of the notification, so it belongs on the content box. Painting it 347 * on the outer bounds of the panel instead leaves the empty border of the notification inside the outline, 348 * where it shows up as a gap between the outline and the message. 349 */ 350 @Test 351 void testRoundedPanelPaintsInsideItsBorder() { 352 NotificationManager.RoundedPanel panel = new NotificationManager.RoundedPanel(); 353 panel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5)); 354 panel.setSize(100, 50); 355 356 BufferedImage image = new BufferedImage(100, 50, BufferedImage.TYPE_INT_ARGB); 357 Graphics2D g = image.createGraphics(); 358 try { 359 panel.paintComponent(g); 360 } finally { 361 g.dispose(); 362 } 363 364 assertEquals(new Rectangle(5, 5, 90, 40), paintedBounds(image), 365 "the notification is painted outside its border, which pads the message instead"); 366 } 367 368 /** 369 * Computes the bounding box of everything that has been painted into the given image. Antialiasing spills a 370 * faint fringe over the edges of the outline, which is not part of what the notification covers, so only the 371 * pixels that are at least half opaque count. 372 * @param image the image to scan 373 * @return the bounds of the painted pixels 374 */ 375 private static Rectangle paintedBounds(BufferedImage image) { 376 Rectangle bounds = null; 377 for (int y = 0; y < image.getHeight(); y++) { 378 for (int x = 0; x < image.getWidth(); x++) { 379 if ((image.getRGB(x, y) >>> 24) >= 128) { 380 Rectangle pixel = new Rectangle(x, y, 1, 1); 381 bounds = bounds == null ? pixel : bounds.union(pixel); 382 } 383 } 384 } 385 return bounds; 386 } 387 }
