Changeset 19643 in josm


Ignore:
Timestamp:
2026-10-06T22:46:46+02:00 (3 days ago)
Author:
gaben
Message:

fix #23002 - keep notifications aligned to the map view

  • follow the map view on fullscreen toggle, maximize and other layout changes
  • scale down icons larger than ImageSizes.NOTIFICATION (new, 32px)
  • fix a possible deadlock when a notification is shown from a worker thread
  • drop duplicates anywhere in the queue, not only the last one
  • remove the transparent margins that blocked clicks on the map
Location:
trunk
Files:
2 added
3 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/org/openstreetmap/josm/gui/Notification.java

    r17628 r19643  
    77
    88import javax.swing.Icon;
     9import javax.swing.ImageIcon;
    910import javax.swing.JEditorPane;
    1011import javax.swing.JOptionPane;
    … …  
    1415import org.openstreetmap.josm.gui.widgets.JMultilineLabel;
    1516import org.openstreetmap.josm.spi.preferences.Config;
     17import org.openstreetmap.josm.tools.ImageProvider.ImageSizes;
     18import org.openstreetmap.josm.tools.ImageResource;
    1619
    1720/**
    … …  
    130133    /**
    131134     * Set an icon to display on the left part of the message window.
     135     * <p>
     136     * Icons larger than {@link ImageSizes#NOTIFICATION} are scaled down.
    132137     *
    133138     * @param icon the icon (null means no icon is displayed)
    … …  
    135140     */
    136141    public Notification setIcon(Icon icon) {
    137         this.icon = icon;
     142        if (icon instanceof ImageIcon && (icon.getIconWidth() > ImageSizes.NOTIFICATION.getAdjustedWidth()
     143                || icon.getIconHeight() > ImageSizes.NOTIFICATION.getAdjustedHeight())) {
     144            this.icon = new ImageResource(((ImageIcon) icon).getImage()).getImageIconBounded(ImageSizes.NOTIFICATION.getImageDimension());
     145        } else {
     146            this.icon = icon;
     147        }
    138148        return this;
    139149    }
  • trunk/src/org/openstreetmap/josm/gui/NotificationManager.java

    r19050 r19643  
    55
    66import java.awt.BasicStroke;
     7import java.awt.BorderLayout;
    78import java.awt.Color;
    89import java.awt.Component;
    … …  
    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;
     24import java.awt.event.HierarchyEvent;
     25import java.awt.event.HierarchyListener;
    1926import java.awt.event.MouseAdapter;
    2027import java.awt.event.MouseEvent;
    … …  
    2633
    2734import javax.swing.AbstractAction;
    28 import javax.swing.BorderFactory;
    2935import javax.swing.GroupLayout;
    3036import javax.swing.JButton;
    … …  
    4147import org.openstreetmap.josm.gui.help.HelpUtil;
    4248import org.openstreetmap.josm.gui.util.GuiHelper;
     49import org.openstreetmap.josm.tools.GuiSizesHelper;
    4350import org.openstreetmap.josm.tools.ImageProvider;
    4451import org.openstreetmap.josm.tools.Logging;
    … …  
    4653/**
    4754 * Manages {@link Notification}s, i.e.&nbsp;displays them on screen.
    48  *
     55 * <p>
    4956 * Don't use this class directly, but use {@link Notification#show()}.
    50  *
     57 * <p>
    5158 * If multiple messages are sent in a short period of time, they are put in
    5259 * a queue and displayed one after the other.
    53  *
     60 * <p>
    5461 * The user can stop the timer (freeze the message) by moving the mouse cursor
    5562 * above the panel. As a visual cue, the background color changes from
    … …  
    6572    private Notification currentNotification;
    6673    private NotificationPanel currentNotificationPanel;
     74
     75    /** the component {@link #currentNotificationPanel} is aligned to, {@code null} while nothing is displayed */
     76    private Component notificationAnchor;
     77
     78    /** keeps the displayed notification aligned when the layout around it changes, e.g. in fullscreen mode */
     79    private final ComponentListener anchorListener = new ComponentAdapter() {
     80        @Override
     81        public void componentResized(ComponentEvent e) {
     82            updateNotificationPosition();
     83        }
     84
     85        @Override
     86        public void componentMoved(ComponentEvent e) {
     87            updateNotificationPosition();
     88        }
     89    };
     90
     91    /** realigns the displayed notification when toggling fullscreen, before the window is painted with it at its old position */
     92    private final HierarchyListener showingListener = e -> {
     93        if ((e.getChangeFlags() & HierarchyEvent.SHOWING_CHANGED) != 0 && e.getComponent().isShowing()) {
     94            updateNotificationPosition();
     95        }
     96    };
     97
     98    /** brings the displayed notification over to the map view as soon as one is opened, and back when it is closed */
     99    private final MapFrameListener mapFrameListener = (oldFrame, newFrame) -> updateNotificationPosition();
     100
    67101    private final Deque<Notification> queue;
    68102
    … …  
    74108    private static NotificationManager instance;
    75109
     110    /** margin between the notification panel and the borders of its anchor, in unscaled pixels */
     111    private static final int MARGIN = 10;
     112
    76113    private static final Color PANEL_SEMITRANSPARENT = new Color(224, 236, 249, 230);
    77114    private static final Color PANEL_OPAQUE = new Color(224, 236, 249);
    … …  
    79116    NotificationManager() {
    80117        queue = new LinkedList<>();
    81         hideTimer = new Timer(Notification.TIME_DEFAULT, e -> this.stopHideTimer());
     118        hideTimer = new Timer(Notification.TIME_DEFAULT, e -> this.stopHideTimer(null));
    82119        hideTimer.setRepeats(false);
    83120        pauseTimer = new Timer(pauseTime.get(), new PauseFinishedEvent());
    … …  
    88125
    89126    /**
    90      * Show the given notification (unless a duplicate notification is being shown at the moment or at the end of the queue)
     127     * Show the given notification (unless a duplicate notification is being shown at the moment or is already queued)
    91128     * @param note The note to show.
    92129     * @see Notification#show()
    … …  
    94131    void showNotification(Notification note) {
    95132        synchronized (queue) {
    96             if (Objects.equals(note, currentNotification) || Objects.equals(note, queue.peekLast())) {
     133            if (Objects.equals(note, currentNotification) || queue.contains(note)) {
    97134                Logging.debug("Dropping duplicate notification {0}", note);
    98135                return;
    99136            }
    100137            queue.add(note);
    101             processQueue();
    102         }
     138        }
     139        // must not run while the monitor is held, see processQueue()
     140        processQueue();
    103141    }
    104142
    … …  
    109147     */
    110148    void replaceExistingNotification(Notification oldNotification, Notification newNotification) {
     149        boolean isDisplayed;
    111150        synchronized (queue) {
    112             if (Objects.equals(oldNotification, currentNotification)) {
    113                 stopHideTimer();
    114             } else {
     151            isDisplayed = Objects.equals(oldNotification, currentNotification);
     152            if (!isDisplayed) {
    115153                queue.remove(oldNotification);
    116154            }
    117             showNotification(newNotification);
    118             processQueue();
    119         }
    120     }
    121 
     155        }
     156        if (isDisplayed) {
     157            // must not run while the monitor is held either, it waits for the EDT as well
     158            stopHideTimer(oldNotification);
     159        }
     160        showNotification(newNotification);
     161    }
     162
     163    /**
     164     * Displays the next queued notification, unless one is being displayed already or the queue is empty.
     165     * <p>
     166     * Only the state transition is guarded by the monitor of {@link #queue}. The rest waits for the EDT, and the EDT
     167     * takes that very monitor as well, so holding it any longer would deadlock every caller
     168     * that is not the EDT itself.
     169     */
    122170    private void processQueue() {
    123         if (running) return;
    124 
    125         currentNotification = queue.poll();
    126         if (currentNotification == null) return;
     171        synchronized (queue) {
     172            if (running) return;
     173
     174            currentNotification = queue.poll();
     175            if (currentNotification == null) return;
     176
     177            // claim the slot before releasing the monitor, so that no concurrent call displays a second notification
     178            running = true;
     179        }
    127180
    128181        GuiHelper.runInEDTAndWait(() -> {
    129             currentNotificationPanel = new NotificationPanel(currentNotification, new FreezeMouseListener(), e -> this.stopHideTimer());
     182            currentNotificationPanel = new NotificationPanel(currentNotification, new FreezeMouseListener(), e -> this.stopHideTimer(null));
     183            currentNotificationPanel.addHierarchyListener(showingListener);
    130184            currentNotificationPanel.validate();
    131185
    132             int margin = 5;
     186            currentNotificationPanel.setSize(currentNotificationPanel.getPreferredSize());
     187
    133188            JFrame parentWindow = MainApplication.getMainFrame();
    134             Dimension size = currentNotificationPanel.getPreferredSize();
    135189            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                 }
    148190                parentWindow.getLayeredPane().add(currentNotificationPanel, JLayeredPane.POPUP_LAYER, 0);
    149 
    150                 currentNotificationPanel.setLocation(x, y);
    151             }
    152             currentNotificationPanel.setSize(size);
     191                MainApplication.addMapFrameListener(mapFrameListener);
     192                updateNotificationPosition();
     193            }
    153194            currentNotificationPanel.setVisible(true);
    154195        });
    155196
    156         running = true;
    157197        elapsedTime = 0;
    158 
    159198        startHideTimer();
     199    }
     200
     201    /**
     202     * Aligns the displayed notification to the map view, or to the content pane while no map view is displayed,
     203     * and keeps listening to that anchor for as long as the notification is displayed.
     204     */
     205    private void updateNotificationPosition() {
     206        JFrame parentWindow = MainApplication.getMainFrame();
     207        if (currentNotificationPanel == null || parentWindow == null) {
     208            return;
     209        }
     210        Component anchor = MainApplication.isDisplayingMapView() ? MainApplication.getMap().mapView : parentWindow.getContentPane();
     211        if (anchor != notificationAnchor) {
     212            // the map view is created and destroyed along with the layers, so the anchor may change while displaying
     213            detachAnchorListener();
     214            notificationAnchor = anchor;
     215            anchor.addComponentListener(anchorListener);
     216        }
     217        // a map view that has just been created is not laid out yet; its first resize event brings the notification over
     218        Component target = anchor.getHeight() > 0 ? anchor : parentWindow.getContentPane();
     219        currentNotificationPanel.setLocation(getNotificationPosition(target, parentWindow.getLayeredPane(),
     220                currentNotificationPanel.getSize(), GuiSizesHelper.getSizeDpiAdjusted(MARGIN)));
     221    }
     222
     223    /**
     224     * Stops listening to the anchor of the notification that is no longer displayed.
     225     */
     226    private void detachAnchorListener() {
     227        if (notificationAnchor != null) {
     228            notificationAnchor.removeComponentListener(anchorListener);
     229            notificationAnchor = null;
     230        }
     231    }
     232
     233    /**
     234     * Computes the position of a notification panel in the coordinate system of {@code container}, which unlike
     235     * the one of the main window does not depend on the window decorations, i.e. on fullscreen mode.
     236     * <p>
     237     * The panel is aligned to the bottom left corner of {@code anchor}, or to its top left corner if it is taller,
     238     * so that the beginning of a long message stays readable.
     239     *
     240     * @param anchor the component the notification is aligned to, e.g. the map view
     241     * @param container the container the notification panel is added to
     242     * @param size the size of the notification panel
     243     * @param margin the margin to keep between the notification panel and the borders of {@code anchor}
     244     * @return the location of the upper left corner of the notification panel
     245     */
     246    static Point getNotificationPosition(Component anchor, Container container, Dimension size, int margin) {
     247        Rectangle bounds = SwingUtilities.convertRectangle(anchor.getParent(), anchor.getBounds(), container);
     248        int y = Math.max(bounds.y + margin, bounds.y + bounds.height - size.height - margin);
     249        return new Point(bounds.x + margin, y);
    160250    }
    161251
    … …  
    170260    }
    171261
    172     private void stopHideTimer() {
    173         hideTimer.stop();
    174         if (currentNotificationPanel != null) {
     262    /**
     263     * Hides the displayed notification and starts the pause before the next one.
     264     *
     265     * @param expected the notification to hide, or {@code null} for whichever is displayed. Nothing happens if
     266     *                 another notification is displayed by now.
     267     */
     268    private void stopHideTimer(Notification expected) {
     269        // may be reached from any thread through replaceExistingNotification()
     270        GuiHelper.runInEDTAndWait(() -> {
     271            if (currentNotificationPanel == null || (expected != null && !Objects.equals(expected, currentNotification))) {
     272                return;
     273            }
     274            hideTimer.stop();
     275            detachAnchorListener();
     276            MainApplication.removeMapFrameListener(mapFrameListener);
    175277            currentNotificationPanel.setVisible(false);
    176278            JFrame parent = MainApplication.getMainFrame();
    … …  
    179281            }
    180282            currentNotificationPanel = null;
    181         }
    182         pauseTimer.restart();
     283            synchronized (queue) {
     284                // forget it, or an identical notification shown during the pause is dropped as a duplicate
     285                currentNotification = null;
     286            }
     287            pauseTimer.restart();
     288        });
    183289    }
    184290
    … …  
    189295            synchronized (queue) {
    190296                running = false;
    191                 processQueue();
    192             }
     297            }
     298            processQueue();
    193299        }
    194300    }
    … …  
    198304        @Override
    199305        public void actionPerformed(ActionEvent e) {
     306            // AWT still delivers the mouse exit event of a panel that has just been removed
    200307            if (currentNotificationPanel != null) {
    201308                currentNotificationPanel.setNotificationBackground(PANEL_SEMITRANSPARENT);
    202309                currentNotificationPanel.repaint();
    203             }
    204             startHideTimer();
     310                startHideTimer();
     311            }
    205312        }
    206313    }
    … …  
    236343
    237344        private void build(final Notification note, MouseListener freeze, ActionListener hideListener) {
     345            // the default FlowLayout would add gaps around the visible notification
     346            setLayout(new BorderLayout());
    238347            JButton btnClose = new JButton();
    239348            btnClose.addActionListener(hideListener);
    240349            btnClose.setIcon(ImageProvider.get("misc", "grey_x"));
    241             btnClose.setPreferredSize(new Dimension(50, 50));
     350            btnClose.setPreferredSize(GuiSizesHelper.getDimensionDpiAdjusted(new Dimension(50, 50)));
    242351            btnClose.setMargin(new Insets(0, 0, 1, 1));
    243352            btnClose.setContentAreaFilled(false);
    … …  
    271380            layout.setAutoCreateContainerGaps(true);
    272381
    273             innerPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
    274             add(innerPanel);
     382            add(innerPanel, BorderLayout.CENTER);
    275383
    276384            JLabel icon = null;
    … …  
    342450            if (unfreezeDelayTimer.isRunning()) {
    343451                unfreezeDelayTimer.stop();
    344             } else {
     452            } else if (currentNotificationPanel != null) {
     453                // AWT still delivers events for a panel that has just been removed
    345454                hideTimer.stop();
    346455                elapsedTime += System.currentTimeMillis() - displayTimeStart;
    … …  
    368477        @Override
    369478        protected void paintComponent(Graphics graphics) {
    370             Graphics2D g = (Graphics2D) graphics;
    371             g.setRenderingHint(
    372                     RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
    373             g.setColor(getBackground());
    374             float lineWidth = 1.4f;
    375             Shape rect = new RoundRectangle2D.Double(
    376                     lineWidth/2d + getInsets().left,
    377                     lineWidth/2d + getInsets().top,
    378                     getWidth() - lineWidth/2d - getInsets().left - getInsets().right,
    379                     getHeight() - lineWidth/2d - getInsets().top - getInsets().bottom,
    380                     20, 20);
    381 
    382             g.fill(rect);
    383             g.setColor(getForeground());
    384             g.setStroke(new BasicStroke(lineWidth));
    385             g.draw(rect);
     479            // paint on a copy, so that neither the antialiasing hint nor the stroke leak into the given context
     480            Graphics2D g = (Graphics2D) graphics.create();
     481            try {
     482                g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
     483                g.setColor(getBackground());
     484                float lineWidth = 1.4f;
     485                // the outline goes on the content box, not around the empty border
     486                Insets insets = getInsets();
     487                Shape rect = new RoundRectangle2D.Double(
     488                        insets.left + lineWidth/2d,
     489                        insets.top + lineWidth/2d,
     490                        getWidth() - insets.left - insets.right - lineWidth,
     491                        getHeight() - insets.top - insets.bottom - lineWidth,
     492                        20, 20);
     493
     494                g.fill(rect);
     495                g.setColor(getForeground());
     496                g.setStroke(new BasicStroke(lineWidth));
     497                g.draw(rect);
     498            } finally {
     499                g.dispose();
     500            }
    386501            super.paintComponent(graphics);
    387502        }
  • trunk/src/org/openstreetmap/josm/tools/ImageProvider.java

    r19641 r19643  
    183183         * @since 16872
    184184         */
    185         HTMLINLINE(24, 24);
     185        HTMLINLINE(24, 24),
     186        /**
     187         * Notification icon maximum size
     188         * @since 19643
     189         */
     190        NOTIFICATION(Config.getPref().getInt("iconsize.notification", 32));
    186191
    187192        private final int virtualWidth;
Note: See TracChangeset for help on using the changeset viewer.