Ticket #23002: 23002.patch

File 23002.patch, 38.2 KB (added by gaben, 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  
    44import static org.openstreetmap.josm.tools.I18n.tr;
    55
    66import java.awt.BasicStroke;
     7import java.awt.BorderLayout;
    78import java.awt.Color;
    89import java.awt.Component;
    910import java.awt.Container;
     
    1213import java.awt.Graphics2D;
    1314import java.awt.Insets;
    1415import java.awt.Point;
     16import java.awt.Rectangle;
    1517import java.awt.RenderingHints;
    1618import java.awt.Shape;
    1719import java.awt.event.ActionEvent;
    1820import java.awt.event.ActionListener;
     21import java.awt.event.ComponentAdapter;
     22import java.awt.event.ComponentEvent;
     23import java.awt.event.ComponentListener;
    1924import java.awt.event.MouseAdapter;
    2025import java.awt.event.MouseEvent;
    2126import java.awt.event.MouseListener;
     
    4045import org.openstreetmap.josm.gui.help.HelpBrowser;
    4146import org.openstreetmap.josm.gui.help.HelpUtil;
    4247import org.openstreetmap.josm.gui.util.GuiHelper;
     48import org.openstreetmap.josm.tools.GuiSizesHelper;
    4349import org.openstreetmap.josm.tools.ImageProvider;
    4450import org.openstreetmap.josm.tools.Logging;
    4551
    4652/**
    4753 * Manages {@link Notification}s, i.e.&nbsp;displays them on screen.
    48  *
     54 * <p>
    4955 * Don't use this class directly, but use {@link Notification#show()}.
    50  *
     56 * <p>
    5157 * If multiple messages are sent in a short period of time, they are put in
    5258 * a queue and displayed one after the other.
    53  *
     59 * <p>
    5460 * The user can stop the timer (freeze the message) by moving the mouse cursor
    5561 * above the panel. As a visual cue, the background color changes from
    5662 * semi-transparent to opaque while the timer is frozen.
     
    6470
    6571    private Notification currentNotification;
    6672    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();
    6789    private final Deque<Notification> queue;
    6890
    6991    private static final IntegerProperty pauseTime = new IntegerProperty("notification-default-pause-time-ms", 300); // milliseconds
     
    7395
    7496    private static NotificationManager instance;
    7597
     98    /** margin between the notification panel and the borders of its anchor, in unscaled pixels */
     99    private static final int MARGIN = 10;
     100
    76101    private static final Color PANEL_SEMITRANSPARENT = new Color(224, 236, 249, 230);
    77102    private static final Color PANEL_OPAQUE = new Color(224, 236, 249);
    78103
    79104    NotificationManager() {
    80105        queue = new LinkedList<>();
    81         hideTimer = new Timer(Notification.TIME_DEFAULT, e -> this.stopHideTimer());
     106        hideTimer = new Timer(Notification.TIME_DEFAULT, e -> this.stopHideTimer(null));
    82107        hideTimer.setRepeats(false);
    83108        pauseTimer = new Timer(pauseTime.get(), new PauseFinishedEvent());
    84109        pauseTimer.setRepeats(false);
     
    87112    }
    88113
    89114    /**
    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)
    91116     * @param note The note to show.
    92117     * @see Notification#show()
    93118     */
    94119    void showNotification(Notification note) {
    95120        synchronized (queue) {
    96             if (Objects.equals(note, currentNotification) || Objects.equals(note, queue.peekLast())) {
     121            if (Objects.equals(note, currentNotification) || queue.contains(note)) {
    97122                Logging.debug("Dropping duplicate notification {0}", note);
    98123                return;
    99124            }
    100125            queue.add(note);
    101             processQueue();
    102         }
     126        }
     127        // must not run while the monitor is held, see processQueue()
     128        processQueue();
    103129    }
    104130
    105131    /**
     
    108134     * @param newNotification the notification to show
    109135     */
    110136    void replaceExistingNotification(Notification oldNotification, Notification newNotification) {
     137        boolean isDisplayed;
    111138        synchronized (queue) {
    112             if (Objects.equals(oldNotification, currentNotification)) {
    113                 stopHideTimer();
    114             } else {
     139            isDisplayed = Objects.equals(oldNotification, currentNotification);
     140            if (!isDisplayed) {
    115141                queue.remove(oldNotification);
    116142            }
    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);
    120150    }
    121151
     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     */
    122159    private void processQueue() {
    123         if (running) return;
     160        synchronized (queue) {
     161            if (running) return;
    124162
    125         currentNotification = queue.poll();
    126         if (currentNotification == null) return;
     163            currentNotification = queue.poll();
     164            if (currentNotification == null) return;
    127165
     166            // claim the slot before releasing the monitor, so that no concurrent call displays a second notification
     167            running = true;
     168        }
     169
    128170        GuiHelper.runInEDTAndWait(() -> {
    129             currentNotificationPanel = new NotificationPanel(currentNotification, new FreezeMouseListener(), e -> this.stopHideTimer());
     171            currentNotificationPanel = new NotificationPanel(currentNotification, new FreezeMouseListener(), e -> this.stopHideTimer(null));
    130172            currentNotificationPanel.validate();
    131173
    132             int margin = 5;
     174            currentNotificationPanel.setSize(currentNotificationPanel.getPreferredSize());
     175
    133176            JFrame parentWindow = MainApplication.getMainFrame();
    134             Dimension size = currentNotificationPanel.getPreferredSize();
    135177            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                 }
    148178                parentWindow.getLayeredPane().add(currentNotificationPanel, JLayeredPane.POPUP_LAYER, 0);
    149 
    150                 currentNotificationPanel.setLocation(x, y);
     179                MainApplication.addMapFrameListener(mapFrameListener);
     180                updateNotificationPosition();
    151181            }
    152             currentNotificationPanel.setSize(size);
    153182            currentNotificationPanel.setVisible(true);
    154183        });
    155184
    156         running = true;
    157185        elapsedTime = 0;
    158 
    159186        startHideTimer();
    160187    }
    161188
     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
    162247    private void startHideTimer() {
     248        if (currentNotification == null) {
     249            return;
     250        }
    163251        int remaining = (int) (currentNotification.getDuration() - elapsedTime);
    164252        if (remaining < 300) {
    165253            remaining = 300;
     
    169257        hideTimer.restart();
    170258    }
    171259
    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);
    175276            currentNotificationPanel.setVisible(false);
    176277            JFrame parent = MainApplication.getMainFrame();
    177278            if (parent != null) {
    178279                parent.getLayeredPane().remove(currentNotificationPanel);
    179280            }
    180281            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        }
    183293    }
    184294
    185295    private final class PauseFinishedEvent implements ActionListener {
     
    188298        public void actionPerformed(ActionEvent e) {
    189299            synchronized (queue) {
    190300                running = false;
    191                 processQueue();
    192             }
     301            }
     302            processQueue();
    193303        }
    194304    }
    195305
     
    197307
    198308        @Override
    199309        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
    200313            if (currentNotificationPanel != null) {
    201314                currentNotificationPanel.setNotificationBackground(PANEL_SEMITRANSPARENT);
    202315                currentNotificationPanel.repaint();
    203             }
    204             startHideTimer();
     316                startHideTimer();
     317            }
    205318        }
    206319    }
    207320
     
    235348        }
    236349
    237350        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());
    238354            JButton btnClose = new JButton();
    239355            btnClose.addActionListener(hideListener);
    240356            btnClose.setIcon(ImageProvider.get("misc", "grey_x"));
    241             btnClose.setPreferredSize(new Dimension(50, 50));
     357            btnClose.setPreferredSize(GuiSizesHelper.getDimensionDpiAdjusted(new Dimension(50, 50)));
    242358            btnClose.setMargin(new Insets(0, 0, 1, 1));
    243359            btnClose.setContentAreaFilled(false);
    244360            // put it in JToolBar to get a better appearance
     
    271387            layout.setAutoCreateContainerGaps(true);
    272388
    273389            innerPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
    274             add(innerPanel);
     390            add(innerPanel, BorderLayout.CENTER);
    275391
    276392            JLabel icon = null;
    277393            if (note.getIcon() != null) {
     
    341457        public void mouseEntered(MouseEvent e) {
    342458            if (unfreezeDelayTimer.isRunning()) {
    343459                unfreezeDelayTimer.stop();
    344             } else {
     460            } else if (currentNotificationPanel != null) {
     461                // AWT still delivers events for a panel that has just been removed
    345462                hideTimer.stop();
    346463                elapsedTime += System.currentTimeMillis() - displayTimeStart;
    347464                currentNotificationPanel.setNotificationBackground(PANEL_OPAQUE);
     
    367484
    368485        @Override
    369486        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);
    381502
    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            }
    386510            super.paintComponent(graphics);
    387511        }
    388512    }
  • 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.
     2package org.openstreetmap.josm.gui;
     3
     4import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
     5import static org.junit.jupiter.api.Assertions.assertEquals;
     6import static org.junit.jupiter.api.Assertions.assertFalse;
     7import static org.junit.jupiter.api.Assertions.assertNotNull;
     8import static org.junit.jupiter.api.Assertions.assertNull;
     9import static org.junit.jupiter.api.Assertions.assertSame;
     10import static org.junit.jupiter.api.Assertions.assertTrue;
     11
     12import java.awt.Component;
     13import java.awt.Dimension;
     14import java.awt.Graphics2D;
     15import java.awt.Point;
     16import java.awt.Rectangle;
     17import java.awt.RenderingHints;
     18import java.awt.event.ActionEvent;
     19import java.awt.event.ActionListener;
     20import java.awt.event.MouseEvent;
     21import java.awt.event.MouseListener;
     22import java.awt.image.BufferedImage;
     23import java.lang.reflect.Field;
     24import java.lang.reflect.Method;
     25import java.util.Deque;
     26import java.util.concurrent.CountDownLatch;
     27import java.util.concurrent.TimeUnit;
     28import java.util.concurrent.atomic.AtomicBoolean;
     29
     30import javax.swing.BorderFactory;
     31import javax.swing.JLayeredPane;
     32import javax.swing.JPanel;
     33import javax.swing.SwingUtilities;
     34import javax.swing.Timer;
     35
     36import org.junit.jupiter.api.Test;
     37import org.openstreetmap.josm.testutils.annotations.BasicPreferences;
     38import org.openstreetmap.josm.testutils.annotations.JosmHome;
     39import org.openstreetmap.josm.tools.ReflectionUtils;
     40
     41/**
     42 * Unit tests of {@link NotificationManager} class.
     43 */
     44@BasicPreferences
     45@JosmHome
     46class 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}