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
---
Index: src/org/openstreetmap/josm/gui/NotificationManager.java
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/src/org/openstreetmap/josm/gui/NotificationManager.java	(revision c407c2a150bcedaa510987ccf81c480adb153fac)
+++ b/src/org/openstreetmap/josm/gui/NotificationManager.java	(revision 5491e778daa528dcefe2521e9aa8bf4706fb833c)
@@ -4,6 +4,7 @@
 import static org.openstreetmap.josm.tools.I18n.tr;
 
 import java.awt.BasicStroke;
+import java.awt.BorderLayout;
 import java.awt.Color;
 import java.awt.Component;
 import java.awt.Container;
@@ -12,10 +13,14 @@
 import java.awt.Graphics2D;
 import java.awt.Insets;
 import java.awt.Point;
+import java.awt.Rectangle;
 import java.awt.RenderingHints;
 import java.awt.Shape;
 import java.awt.event.ActionEvent;
 import java.awt.event.ActionListener;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.awt.event.ComponentListener;
 import java.awt.event.MouseAdapter;
 import java.awt.event.MouseEvent;
 import java.awt.event.MouseListener;
@@ -40,17 +45,18 @@
 import org.openstreetmap.josm.gui.help.HelpBrowser;
 import org.openstreetmap.josm.gui.help.HelpUtil;
 import org.openstreetmap.josm.gui.util.GuiHelper;
+import org.openstreetmap.josm.tools.GuiSizesHelper;
 import org.openstreetmap.josm.tools.ImageProvider;
 import org.openstreetmap.josm.tools.Logging;
 
 /**
  * Manages {@link Notification}s, i.e.&nbsp;displays them on screen.
- *
+ * <p>
  * Don't use this class directly, but use {@link Notification#show()}.
- *
+ * <p>
  * If multiple messages are sent in a short period of time, they are put in
  * a queue and displayed one after the other.
- *
+ * <p>
  * The user can stop the timer (freeze the message) by moving the mouse cursor
  * above the panel. As a visual cue, the background color changes from
  * semi-transparent to opaque while the timer is frozen.
@@ -64,6 +70,22 @@
 
     private Notification currentNotification;
     private NotificationPanel currentNotificationPanel;
+    /** the component {@link #currentNotificationPanel} is aligned to, {@code null} while nothing is displayed */
+    private Component notificationAnchor;
+    /** keeps the displayed notification aligned when the layout around it changes, e.g. in fullscreen mode */
+    private final ComponentListener anchorListener = new ComponentAdapter() {
+        @Override
+        public void componentResized(ComponentEvent e) {
+            updateNotificationPosition();
+        }
+
+        @Override
+        public void componentMoved(ComponentEvent e) {
+            updateNotificationPosition();
+        }
+    };
+    /** brings the displayed notification over to the map view as soon as one is opened, and back when it is closed */
+    private final MapFrameListener mapFrameListener = (oldFrame, newFrame) -> updateNotificationPosition();
     private final Deque<Notification> queue;
 
     private static final IntegerProperty pauseTime = new IntegerProperty("notification-default-pause-time-ms", 300); // milliseconds
@@ -73,12 +95,15 @@
 
     private static NotificationManager instance;
 
+    /** margin between the notification panel and the borders of its anchor, in unscaled pixels */
+    private static final int MARGIN = 10;
+
     private static final Color PANEL_SEMITRANSPARENT = new Color(224, 236, 249, 230);
     private static final Color PANEL_OPAQUE = new Color(224, 236, 249);
 
     NotificationManager() {
         queue = new LinkedList<>();
-        hideTimer = new Timer(Notification.TIME_DEFAULT, e -> this.stopHideTimer());
+        hideTimer = new Timer(Notification.TIME_DEFAULT, e -> this.stopHideTimer(null));
         hideTimer.setRepeats(false);
         pauseTimer = new Timer(pauseTime.get(), new PauseFinishedEvent());
         pauseTimer.setRepeats(false);
@@ -87,19 +112,20 @@
     }
 
     /**
-     * Show the given notification (unless a duplicate notification is being shown at the moment or at the end of the queue)
+     * Show the given notification (unless a duplicate notification is being shown at the moment or is already queued)
      * @param note The note to show.
      * @see Notification#show()
      */
     void showNotification(Notification note) {
         synchronized (queue) {
-            if (Objects.equals(note, currentNotification) || Objects.equals(note, queue.peekLast())) {
+            if (Objects.equals(note, currentNotification) || queue.contains(note)) {
                 Logging.debug("Dropping duplicate notification {0}", note);
                 return;
             }
             queue.add(note);
-            processQueue();
-        }
+        }
+        // must not run while the monitor is held, see processQueue()
+        processQueue();
     }
 
     /**
@@ -108,58 +134,120 @@
      * @param newNotification the notification to show
      */
     void replaceExistingNotification(Notification oldNotification, Notification newNotification) {
+        boolean isDisplayed;
         synchronized (queue) {
-            if (Objects.equals(oldNotification, currentNotification)) {
-                stopHideTimer();
-            } else {
+            isDisplayed = Objects.equals(oldNotification, currentNotification);
+            if (!isDisplayed) {
                 queue.remove(oldNotification);
             }
-            showNotification(newNotification);
-            processQueue();
-        }
+        }
+        if (isDisplayed) {
+            // must not run while the monitor is held either, it waits for the EDT as well
+            stopHideTimer(oldNotification);
+        }
+        // processes the queue itself
+        showNotification(newNotification);
     }
 
+    /**
+     * Displays the next queued notification, unless one is being displayed already or the queue is empty.
+     * <p>
+     * Only the state transition is guarded by the monitor of {@link #queue}. The rest waits for the EDT, and the EDT
+     * takes that very monitor in {@link PauseFinishedEvent}, so holding it any longer would deadlock every caller
+     * that is not the EDT itself.
+     */
     private void processQueue() {
-        if (running) return;
+        synchronized (queue) {
+            if (running) return;
 
-        currentNotification = queue.poll();
-        if (currentNotification == null) return;
+            currentNotification = queue.poll();
+            if (currentNotification == null) return;
 
+            // claim the slot before releasing the monitor, so that no concurrent call displays a second notification
+            running = true;
+        }
+
         GuiHelper.runInEDTAndWait(() -> {
-            currentNotificationPanel = new NotificationPanel(currentNotification, new FreezeMouseListener(), e -> this.stopHideTimer());
+            currentNotificationPanel = new NotificationPanel(currentNotification, new FreezeMouseListener(), e -> this.stopHideTimer(null));
             currentNotificationPanel.validate();
 
-            int margin = 5;
+            currentNotificationPanel.setSize(currentNotificationPanel.getPreferredSize());
+
             JFrame parentWindow = MainApplication.getMainFrame();
-            Dimension size = currentNotificationPanel.getPreferredSize();
             if (parentWindow != null) {
-                int x;
-                int y;
-                MapFrame map = MainApplication.getMap();
-                if (MainApplication.isDisplayingMapView() && map.mapView.getHeight() > 0) {
-                    MapView mv = map.mapView;
-                    Point mapViewPos = SwingUtilities.convertPoint(mv.getParent(), mv.getX(), mv.getY(), MainApplication.getMainFrame());
-                    x = mapViewPos.x + margin;
-                    y = mapViewPos.y + mv.getHeight() - map.statusLine.getHeight() - size.height - margin;
-                } else {
-                    x = margin;
-                    y = parentWindow.getHeight() - MainApplication.getToolbar().control.getSize().height - size.height - margin;
-                }
                 parentWindow.getLayeredPane().add(currentNotificationPanel, JLayeredPane.POPUP_LAYER, 0);
-
-                currentNotificationPanel.setLocation(x, y);
+                MainApplication.addMapFrameListener(mapFrameListener);
+                updateNotificationPosition();
             }
-            currentNotificationPanel.setSize(size);
             currentNotificationPanel.setVisible(true);
         });
 
-        running = true;
         elapsedTime = 0;
-
         startHideTimer();
     }
 
+    /**
+     * Aligns the currently displayed notification to its anchor: the map view, or the whole content pane while no
+     * map view is displayed. Also (re-)registers the listener that keeps it aligned for as long as it is displayed,
+     * so that the notification follows its anchor when the layout changes, e.g. when toggling fullscreen mode, the
+     * toggle dialogs panel or the status line.
+     */
+    private void updateNotificationPosition() {
+        JFrame parentWindow = MainApplication.getMainFrame();
+        if (currentNotificationPanel == null || parentWindow == null) {
+            return;
+        }
+        MapFrame map = MainApplication.getMap();
+        // The notification is aligned to the map view, or to the whole content pane if there is none.
+        Component anchor = MainApplication.isDisplayingMapView() ? map.mapView : parentWindow.getContentPane();
+        if (anchor != notificationAnchor) {
+            // the map view is created and destroyed along with the layers, so the anchor may change while displaying
+            detachAnchorListener();
+            notificationAnchor = anchor;
+            anchor.addComponentListener(anchorListener);
+        }
+        // a map view that has just been created is not laid out yet; its first resize event brings the notification over
+        Component target = anchor.getHeight() > 0 ? anchor : parentWindow.getContentPane();
+        currentNotificationPanel.setLocation(getNotificationPosition(target, parentWindow.getLayeredPane(),
+                currentNotificationPanel.getSize(), GuiSizesHelper.getSizeDpiAdjusted(MARGIN)));
+    }
+
+    /**
+     * Stops listening to the anchor of the notification that is no longer displayed.
+     */
+    private void detachAnchorListener() {
+        if (notificationAnchor != null) {
+            notificationAnchor.removeComponentListener(anchorListener);
+            notificationAnchor = null;
+        }
+    }
+
+    /**
+     * Computes the position of a notification panel, expressed in the coordinate system of {@code container}.
+     * <p>
+     * The panel is aligned to the bottom left corner of {@code anchor}. The position has to be computed in the
+     * coordinate system of the container the panel is added to, and not in the one of the main window: the latter
+     * is additionally offset by the window decoration insets, which are only present when not in fullscreen mode.
+     * <p>
+     * A panel taller than its anchor is aligned to the top left corner instead, so that the beginning of a long
+     * message stays readable rather than being cut off by the upper border of the window.
+     *
+     * @param anchor the component the notification is aligned to, e.g. the map view
+     * @param container the container the notification panel is added to
+     * @param size the size of the notification panel
+     * @param margin the margin to keep between the notification panel and the borders of {@code anchor}
+     * @return the location of the upper left corner of the notification panel
+     */
+    static Point getNotificationPosition(Component anchor, Container container, Dimension size, int margin) {
+        Rectangle bounds = SwingUtilities.convertRectangle(anchor.getParent(), anchor.getBounds(), container);
+        int y = Math.max(bounds.y + margin, bounds.y + bounds.height - size.height - margin);
+        return new Point(bounds.x + margin, y);
+    }
+
     private void startHideTimer() {
+        if (currentNotification == null) {
+            return;
+        }
         int remaining = (int) (currentNotification.getDuration() - elapsedTime);
         if (remaining < 300) {
             remaining = 300;
@@ -169,17 +257,39 @@
         hideTimer.restart();
     }
 
-    private void stopHideTimer() {
-        hideTimer.stop();
-        if (currentNotificationPanel != null) {
+    /**
+     * Hides the displayed notification and starts the pause before the next one.
+     *
+     * @param expected the notification that is meant to be hidden, or {@code null} for whichever is displayed.
+     *                 Nothing happens when another notification has been promoted in the meantime, which the
+     *                 queue does on the EDT while a caller from another thread is on its way here.
+     */
+    private void stopHideTimer(Notification expected) {
+        // may be reached from any thread through replaceExistingNotification()
+        boolean hidden = Boolean.TRUE.equals(GuiHelper.runInEDTAndWaitAndReturn(() -> {
+            if (currentNotificationPanel == null || (expected != null && !Objects.equals(expected, currentNotification))) {
+                return Boolean.FALSE;
+            }
+            hideTimer.stop();
+            detachAnchorListener();
+            MainApplication.removeMapFrameListener(mapFrameListener);
             currentNotificationPanel.setVisible(false);
             JFrame parent = MainApplication.getMainFrame();
             if (parent != null) {
                 parent.getLayeredPane().remove(currentNotificationPanel);
             }
             currentNotificationPanel = null;
-        }
-        pauseTimer.restart();
+            return Boolean.TRUE;
+        }));
+        if (hidden) {
+            // the monitor is taken after waiting for the EDT, never around it, see processQueue(). Nothing else can
+            // set the field in between: processQueue() bails out until the pause below has set running back to false
+            synchronized (queue) {
+                // forget it, or an identical notification triggered before the pause is over counts as a duplicate
+                currentNotification = null;
+            }
+            pauseTimer.restart();
+        }
     }
 
     private final class PauseFinishedEvent implements ActionListener {
@@ -188,8 +298,8 @@
         public void actionPerformed(ActionEvent e) {
             synchronized (queue) {
                 running = false;
-                processQueue();
-            }
+            }
+            processQueue();
         }
     }
 
@@ -197,11 +307,14 @@
 
         @Override
         public void actionPerformed(ActionEvent e) {
+            // nothing to unfreeze if the notification has been hidden in the meantime: AWT still delivers the
+            // mouse exit event of a panel that has just been removed, and restarting the hide timer for a
+            // notification that is gone only delays the next one
             if (currentNotificationPanel != null) {
                 currentNotificationPanel.setNotificationBackground(PANEL_SEMITRANSPARENT);
                 currentNotificationPanel.repaint();
-            }
-            startHideTimer();
+                startHideTimer();
+            }
         }
     }
 
@@ -235,10 +348,13 @@
         }
 
         private void build(final Notification note, MouseListener freeze, ActionListener hideListener) {
+            // BorderLayout lets the inner panel fill this panel, so that the visible notification is exactly
+            // where it has been positioned, without the extra gaps a FlowLayout would add around it
+            setLayout(new BorderLayout());
             JButton btnClose = new JButton();
             btnClose.addActionListener(hideListener);
             btnClose.setIcon(ImageProvider.get("misc", "grey_x"));
-            btnClose.setPreferredSize(new Dimension(50, 50));
+            btnClose.setPreferredSize(GuiSizesHelper.getDimensionDpiAdjusted(new Dimension(50, 50)));
             btnClose.setMargin(new Insets(0, 0, 1, 1));
             btnClose.setContentAreaFilled(false);
             // put it in JToolBar to get a better appearance
@@ -271,7 +387,7 @@
             layout.setAutoCreateContainerGaps(true);
 
             innerPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
-            add(innerPanel);
+            add(innerPanel, BorderLayout.CENTER);
 
             JLabel icon = null;
             if (note.getIcon() != null) {
@@ -341,7 +457,8 @@
         public void mouseEntered(MouseEvent e) {
             if (unfreezeDelayTimer.isRunning()) {
                 unfreezeDelayTimer.stop();
-            } else {
+            } else if (currentNotificationPanel != null) {
+                // AWT still delivers events for a panel that has just been removed
                 hideTimer.stop();
                 elapsedTime += System.currentTimeMillis() - displayTimeStart;
                 currentNotificationPanel.setNotificationBackground(PANEL_OPAQUE);
@@ -367,22 +484,29 @@
 
         @Override
         protected void paintComponent(Graphics graphics) {
-            Graphics2D g = (Graphics2D) graphics;
-            g.setRenderingHint(
-                    RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
-            g.setColor(getBackground());
-            float lineWidth = 1.4f;
-            Shape rect = new RoundRectangle2D.Double(
-                    lineWidth/2d + getInsets().left,
-                    lineWidth/2d + getInsets().top,
-                    getWidth() - lineWidth/2d - getInsets().left - getInsets().right,
-                    getHeight() - lineWidth/2d - getInsets().top - getInsets().bottom,
-                    20, 20);
+            // paint on a copy, so that neither the antialiasing hint nor the stroke leak into the given context
+            Graphics2D g = (Graphics2D) graphics.create();
+            try {
+                g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+                g.setColor(getBackground());
+                float lineWidth = 1.4f;
+                // the outline is the visible border of the notification, so it goes on the content box: painting it
+                // on the outer bounds instead turns the empty border of the panel into a gap around the message
+                Insets insets = getInsets();
+                Shape rect = new RoundRectangle2D.Double(
+                        insets.left + lineWidth/2d,
+                        insets.top + lineWidth/2d,
+                        getWidth() - insets.left - insets.right - lineWidth,
+                        getHeight() - insets.top - insets.bottom - lineWidth,
+                        20, 20);
 
-            g.fill(rect);
-            g.setColor(getForeground());
-            g.setStroke(new BasicStroke(lineWidth));
-            g.draw(rect);
+                g.fill(rect);
+                g.setColor(getForeground());
+                g.setStroke(new BasicStroke(lineWidth));
+                g.draw(rect);
+            } finally {
+                g.dispose();
+            }
             super.paintComponent(graphics);
         }
     }
Index: 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
--- /dev/null	(revision 5491e778daa528dcefe2521e9aa8bf4706fb833c)
+++ b/test/unit/org/openstreetmap/josm/gui/NotificationManagerTest.java	(revision 5491e778daa528dcefe2521e9aa8bf4706fb833c)
@@ -0,0 +1,387 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.awt.Component;
+import java.awt.Dimension;
+import java.awt.Graphics2D;
+import java.awt.Point;
+import java.awt.Rectangle;
+import java.awt.RenderingHints;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.MouseEvent;
+import java.awt.event.MouseListener;
+import java.awt.image.BufferedImage;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Deque;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import javax.swing.BorderFactory;
+import javax.swing.JLayeredPane;
+import javax.swing.JPanel;
+import javax.swing.SwingUtilities;
+import javax.swing.Timer;
+
+import org.junit.jupiter.api.Test;
+import org.openstreetmap.josm.testutils.annotations.BasicPreferences;
+import org.openstreetmap.josm.testutils.annotations.JosmHome;
+import org.openstreetmap.josm.tools.ReflectionUtils;
+
+/**
+ * Unit tests of {@link NotificationManager} class.
+ */
+@BasicPreferences
+@JosmHome
+class NotificationManagerTest {
+
+    private static final int MARGIN = 10;
+    private static final Dimension NOTIFICATION_SIZE = new Dimension(300, 60);
+    /** how long a thread may take before it counts as stuck; generous, it only has to outlast a slow machine */
+    private static final int TIMEOUT_SECONDS = 10;
+
+    /**
+     * Builds a hierarchy similar to the one of the main window: a layered pane holding a content pane (below the
+     * menu bar) which in turn holds the map view (right of the side toolbar, above the status line).
+     * @param layeredPane the layered pane to fill
+     * @return the map view stand-in
+     */
+    private static JPanel buildMainWindowHierarchy(JLayeredPane layeredPane) {
+        layeredPane.setBounds(0, 0, 1000, 800);
+
+        JPanel contentPane = new JPanel(null);
+        // below the menu bar
+        contentPane.setBounds(0, 25, 1000, 775);
+        layeredPane.add(contentPane);
+
+        JPanel mapView = new JPanel();
+        // right of the side toolbar, above the status line
+        mapView.setBounds(30, 0, 970, 740);
+        contentPane.add(mapView);
+
+        return mapView;
+    }
+
+    /**
+     * Unit test of {@link NotificationManager#getNotificationPosition} aligning to the map view.
+     */
+    @Test
+    void testGetNotificationPositionMapView() {
+        JLayeredPane layeredPane = new JLayeredPane();
+        JPanel mapView = buildMainWindowHierarchy(layeredPane);
+
+        assertEquals(new Point(30 + MARGIN, 25 + 740 - NOTIFICATION_SIZE.height - MARGIN),
+                NotificationManager.getNotificationPosition(mapView, layeredPane, NOTIFICATION_SIZE, MARGIN));
+    }
+
+    /**
+     * Unit test of {@link NotificationManager#getNotificationPosition} aligning to the content pane, which is what
+     * happens while no map view is displayed.
+     */
+    @Test
+    void testGetNotificationPositionContentPane() {
+        JLayeredPane layeredPane = new JLayeredPane();
+        buildMainWindowHierarchy(layeredPane);
+        JPanel contentPane = (JPanel) layeredPane.getComponent(0);
+
+        assertEquals(new Point(MARGIN, 25 + 775 - NOTIFICATION_SIZE.height - MARGIN),
+                NotificationManager.getNotificationPosition(contentPane, layeredPane, NOTIFICATION_SIZE, MARGIN));
+    }
+
+    /**
+     * Non-regression test for <a href="https://josm.openstreetmap.de/ticket/23002">#23002</a>: the position must not
+     * depend on the insets of the window decoration, which are only there when not in fullscreen mode.
+     */
+    @Test
+    void testTicket23002() {
+        JLayeredPane layeredPane = new JLayeredPane();
+        JPanel mapView = buildMainWindowHierarchy(layeredPane);
+        Point fullscreen = NotificationManager.getNotificationPosition(mapView, layeredPane, NOTIFICATION_SIZE, MARGIN);
+
+        // simulate the window decoration: within the frame, the layered pane is offset by the frame insets
+        JPanel frame = new JPanel(null);
+        frame.setBounds(0, 0, 1010, 840);
+        JPanel rootPane = new JPanel(null);
+        rootPane.setBounds(5, 35, 1000, 800);
+        frame.add(rootPane);
+        rootPane.add(layeredPane);
+
+        Point windowed = NotificationManager.getNotificationPosition(mapView, layeredPane, NOTIFICATION_SIZE, MARGIN);
+        assertEquals(fullscreen, windowed);
+
+        // the notification panel is added to the layered pane, so its position has to keep it inside the map view
+        Rectangle notification = new Rectangle(windowed, NOTIFICATION_SIZE);
+        Rectangle mapViewBounds = SwingUtilities.convertRectangle(mapView.getParent(), mapView.getBounds(), layeredPane);
+        assertTrue(mapViewBounds.contains(notification), notification + " is not inside the map view " + mapViewBounds);
+    }
+
+    /**
+     * Unit test of {@link NotificationManager#getNotificationPosition} with a notification taller than its anchor:
+     * it has to stay aligned to the top of the anchor, instead of starting above the upper border of the window.
+     */
+    @Test
+    void testGetNotificationPositionTallerThanAnchor() {
+        JLayeredPane layeredPane = new JLayeredPane();
+        JPanel mapView = buildMainWindowHierarchy(layeredPane);
+        // a long message in a small window is easily taller than the whole main window
+        Dimension size = new Dimension(480, 800);
+
+        // the top left corner of the map view, instead of a negative y above the upper border of the window
+        assertEquals(new Point(30 + MARGIN, 25 + MARGIN),
+                NotificationManager.getNotificationPosition(mapView, layeredPane, size, MARGIN));
+    }
+
+    private static Object getFieldValue(NotificationManager manager, String name) throws ReflectiveOperationException {
+        Field field = NotificationManager.class.getDeclaredField(name);
+        ReflectionUtils.setObjectsAccessible(field);
+        return field.get(manager);
+    }
+
+    private static Method stopHideTimer() throws ReflectiveOperationException {
+        Method stopHideTimer = NotificationManager.class.getDeclaredMethod("stopHideTimer", Notification.class);
+        ReflectionUtils.setObjectsAccessible(stopHideTimer);
+        return stopHideTimer;
+    }
+
+    private static void hideCurrentNotification(NotificationManager manager) throws ReflectiveOperationException {
+        stopHideTimer().invoke(manager, (Notification) null);
+    }
+
+    /**
+     * Tries to take the monitor of {@code lock} from another thread.
+     * @param lock the object to synchronize on
+     * @return {@code false} if some other thread holds the monitor for longer than a second
+     * @throws InterruptedException if interrupted while waiting
+     */
+    private static boolean canTakeMonitorOf(Object lock) throws InterruptedException {
+        CountDownLatch taken = new CountDownLatch(1);
+        Thread probe = new Thread(() -> {
+            synchronized (lock) {
+                taken.countDown();
+            }
+        }, "notification-manager-test-monitor");
+        probe.setDaemon(true);
+        probe.start();
+        return taken.await(1, TimeUnit.SECONDS);
+    }
+
+    /**
+     * {@link NotificationManager#showNotification} must not hold the monitor of its queue while it waits for the
+     * EDT, because the EDT takes that same monitor once the pause between two notifications is over. Otherwise a
+     * notification shown from a worker thread deadlocks with it and freezes the whole user interface.
+     * <p>
+     * The deadlock itself is not reproduced here, as it would wedge the EDT for the rest of the JVM. The monitor
+     * is watched from the EDT instead, where the panel of the notification is built.
+     * @throws Exception in case of error
+     */
+    @Test
+    void testShowNotificationFromWorkerThreadDoesNotFreezeEdt() throws Exception {
+        NotificationManager manager = new NotificationManager();
+        Object queue = getFieldValue(manager, "queue");
+        AtomicBoolean monitorWasHeld = new AtomicBoolean();
+
+        // getContent() is called on the EDT, while showNotification() is still on the stack of the worker thread
+        Notification note = new Notification("shown from a worker thread") {
+            @Override
+            public Component getContent() {
+                try {
+                    monitorWasHeld.set(!canTakeMonitorOf(queue));
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                }
+                return super.getContent();
+            }
+        };
+
+        Thread worker = new Thread(() -> manager.showNotification(note), "notification-manager-test");
+        worker.setDaemon(true);
+        worker.start();
+        worker.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
+
+        assertFalse(worker.isAlive(), "showNotification() never returned");
+        assertFalse(monitorWasHeld.get(),
+                "showNotification() held the monitor of the queue while waiting for the EDT, which deadlocks with PauseFinishedEvent");
+    }
+
+    /**
+     * AWT still delivers mouse events for a notification panel that has just been removed, so the listener that
+     * freezes the hide timer has to cope with a notification that is not displayed any more.
+     * @throws Exception in case of error
+     */
+    @Test
+    void testMouseEnteredAfterNotificationHidden() throws Exception {
+        NotificationManager manager = new NotificationManager();
+        manager.showNotification(new Notification("hover me").setDuration(Notification.TIME_LONG));
+
+        Component panel = (Component) getFieldValue(manager, "currentNotificationPanel");
+        assertNotNull(panel, "no notification panel was built");
+
+        hideCurrentNotification(manager);
+        assertNull(getFieldValue(manager, "currentNotificationPanel"), "notification panel was not removed");
+
+        MouseEvent event = new MouseEvent(panel, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(),
+                0, 1, 1, 0, false, MouseEvent.NOBUTTON);
+        for (MouseListener listener : panel.getMouseListeners()) {
+            assertDoesNotThrow(() -> listener.mouseEntered(event), "the mouse entered a panel that is no longer displayed");
+        }
+    }
+
+    /**
+     * The mouse exit event AWT delivers for a notification panel that has just been removed must not restart the
+     * hide timer: there is nothing left to hide, and the next queued notification would only be pushed back by
+     * another pause.
+     * @throws Exception in case of error
+     */
+    @Test
+    void testUnfreezeAfterNotificationHidden() throws Exception {
+        NotificationManager manager = new NotificationManager();
+        manager.showNotification(new Notification("hover me").setDuration(Notification.TIME_LONG));
+        assertNotNull(getFieldValue(manager, "currentNotificationPanel"), "no notification panel was built");
+
+        hideCurrentNotification(manager);
+        Timer hideTimer = (Timer) getFieldValue(manager, "hideTimer");
+        assertFalse(hideTimer.isRunning(), "the hide timer was not stopped");
+
+        // the mouse leaves the panel that has just been removed, which fires the unfreeze event shortly after
+        Timer unfreezeDelayTimer = (Timer) getFieldValue(manager, "unfreezeDelayTimer");
+        ActionEvent event = new ActionEvent(unfreezeDelayTimer, ActionEvent.ACTION_PERFORMED, null);
+        for (ActionListener listener : unfreezeDelayTimer.getActionListeners()) {
+            listener.actionPerformed(event);
+        }
+
+        assertFalse(hideTimer.isRunning(), "the hide timer was restarted for a notification that is not displayed");
+    }
+
+    /**
+     * {@link NotificationManager#replaceExistingNotification} looks up whether the notification to replace is the
+     * displayed one, releases the monitor of the queue and only hides it afterwards. The queue may have moved on in
+     * between, and the unrelated notification that took the slot must not be hidden in its place.
+     * @throws Exception in case of error
+     */
+    @Test
+    void testReplaceNotificationThatIsNoLongerDisplayed() throws Exception {
+        NotificationManager manager = new NotificationManager();
+        manager.showNotification(new Notification("displayed"));
+        Object panel = getFieldValue(manager, "currentNotificationPanel");
+        assertNotNull(panel, "no notification panel was built");
+
+        stopHideTimer().invoke(manager, new Notification("replaced while the queue moved on"));
+        assertSame(panel, getFieldValue(manager, "currentNotificationPanel"),
+                "hiding a notification that is gone removed the displayed one instead");
+
+        // the displayed notification is of course still hidden when it is the one being replaced
+        stopHideTimer().invoke(manager, getFieldValue(manager, "currentNotification"));
+        assertNull(getFieldValue(manager, "currentNotificationPanel"), "notification panel was not removed");
+    }
+
+    /**
+     * A notification equal to one that is already waiting in the queue is dropped, wherever in the queue it sits.
+     * @throws Exception in case of error
+     */
+    @Test
+    void testDuplicateAnywhereInQueueIsDropped() throws Exception {
+        NotificationManager manager = new NotificationManager();
+        manager.showNotification(new Notification("displayed"));
+        manager.showNotification(new Notification("queued first"));
+        manager.showNotification(new Notification("queued second"));
+        // equal to a notification that is in the queue, but not to the last one
+        manager.showNotification(new Notification("queued first"));
+
+        Deque<?> queue = (Deque<?>) getFieldValue(manager, "queue");
+        assertEquals(2, queue.size(), "duplicate of a queued notification was queued again: " + queue);
+    }
+
+    /**
+     * A notification only counts as a duplicate for as long as the one it equals is really displayed. The same
+     * message triggered right after the previous one disappeared has to show up again, rather than being dropped
+     * until the pause before the next notification is over.
+     * @throws Exception in case of error
+     */
+    @Test
+    void testSameNotificationRightAfterTheLastOneDisappeared() throws Exception {
+        NotificationManager manager = new NotificationManager();
+        manager.showNotification(new Notification("same message"));
+        assertNotNull(getFieldValue(manager, "currentNotificationPanel"), "no notification panel was built");
+
+        hideCurrentNotification(manager);
+        assertNull(getFieldValue(manager, "currentNotification"), "the notification that disappeared is still current");
+
+        manager.showNotification(new Notification("same message"));
+        Deque<?> queue = (Deque<?>) getFieldValue(manager, "queue");
+        assertEquals(1, queue.size(), "a notification triggered right after an identical one disappeared was dropped");
+    }
+
+    /**
+     * The rounded panel paints with antialiasing, but on a copy of the graphics context it is handed, so that the
+     * hint does not leak into everything painted afterwards.
+     */
+    @Test
+    void testRoundedPanelKeepsRenderingHints() {
+        NotificationManager.RoundedPanel panel = new NotificationManager.RoundedPanel();
+        panel.setSize(100, 50);
+
+        BufferedImage image = new BufferedImage(100, 50, BufferedImage.TYPE_INT_ARGB);
+        Graphics2D g = image.createGraphics();
+        try {
+            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF);
+            panel.paintComponent(g);
+            assertEquals(RenderingHints.VALUE_ANTIALIAS_OFF, g.getRenderingHint(RenderingHints.KEY_ANTIALIASING),
+                    "the notification panel changed the antialiasing hint of its caller");
+        } finally {
+            g.dispose();
+        }
+    }
+
+    /**
+     * The rounded outline is the visible border of the notification, so it belongs on the content box. Painting it
+     * on the outer bounds of the panel instead leaves the empty border of the notification inside the outline,
+     * where it shows up as a gap between the outline and the message.
+     */
+    @Test
+    void testRoundedPanelPaintsInsideItsBorder() {
+        NotificationManager.RoundedPanel panel = new NotificationManager.RoundedPanel();
+        panel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
+        panel.setSize(100, 50);
+
+        BufferedImage image = new BufferedImage(100, 50, BufferedImage.TYPE_INT_ARGB);
+        Graphics2D g = image.createGraphics();
+        try {
+            panel.paintComponent(g);
+        } finally {
+            g.dispose();
+        }
+
+        assertEquals(new Rectangle(5, 5, 90, 40), paintedBounds(image),
+                "the notification is painted outside its border, which pads the message instead");
+    }
+
+    /**
+     * Computes the bounding box of everything that has been painted into the given image. Antialiasing spills a
+     * faint fringe over the edges of the outline, which is not part of what the notification covers, so only the
+     * pixels that are at least half opaque count.
+     * @param image the image to scan
+     * @return the bounds of the painted pixels
+     */
+    private static Rectangle paintedBounds(BufferedImage image) {
+        Rectangle bounds = null;
+        for (int y = 0; y < image.getHeight(); y++) {
+            for (int x = 0; x < image.getWidth(); x++) {
+                if ((image.getRGB(x, y) >>> 24) >= 128) {
+                    Rectangle pixel = new Rectangle(x, y, 1, 1);
+                    bounds = bounds == null ? pixel : bounds.union(pixel);
+                }
+            }
+        }
+        return bounds;
+    }
+}
