001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.BasicStroke;
007import java.awt.Color;
008import java.awt.Component;
009import java.awt.Container;
010import java.awt.Dimension;
011import java.awt.Graphics;
012import java.awt.Graphics2D;
013import java.awt.Insets;
014import java.awt.Point;
015import java.awt.RenderingHints;
016import java.awt.Shape;
017import java.awt.event.ActionEvent;
018import java.awt.event.ActionListener;
019import java.awt.event.MouseAdapter;
020import java.awt.event.MouseEvent;
021import java.awt.event.MouseListener;
022import java.awt.geom.RoundRectangle2D;
023import java.util.LinkedList;
024import java.util.Queue;
025
026import javax.swing.AbstractAction;
027import javax.swing.BorderFactory;
028import javax.swing.GroupLayout;
029import javax.swing.JButton;
030import javax.swing.JFrame;
031import javax.swing.JLabel;
032import javax.swing.JLayeredPane;
033import javax.swing.JPanel;
034import javax.swing.JToolBar;
035import javax.swing.SwingUtilities;
036import javax.swing.Timer;
037
038import org.openstreetmap.josm.Main;
039import org.openstreetmap.josm.data.preferences.IntegerProperty;
040import org.openstreetmap.josm.gui.help.HelpBrowser;
041import org.openstreetmap.josm.gui.help.HelpUtil;
042import org.openstreetmap.josm.tools.ImageProvider;
043
044/**
045 * Manages {@link Notification}s, i.e. displays them on screen.
046 *
047 * Don't use this class directly, but use {@link Notification#show()}.
048 *
049 * If multiple messages are sent in a short period of time, they are put in
050 * a queue and displayed one after the other.
051 *
052 * The user can stop the timer (freeze the message) by moving the mouse cursor
053 * above the panel. As a visual cue, the background color changes from
054 * semi-transparent to opaque while the timer is frozen.
055 */
056class NotificationManager {
057
058    private final Timer hideTimer; // started when message is shown, responsible for hiding the message
059    private final Timer pauseTimer; // makes sure, there is a small pause between two consecutive messages
060    private final Timer unfreezeDelayTimer; // tiny delay before resuming the timer when mouse cursor is moved off the panel
061    private boolean running;
062
063    private Notification currentNotification;
064    private NotificationPanel currentNotificationPanel;
065    private final Queue<Notification> queue;
066
067    private static IntegerProperty pauseTime = new IntegerProperty("notification-default-pause-time-ms", 300); // milliseconds
068
069    private long displayTimeStart;
070    private long elapsedTime;
071
072    private static NotificationManager instance;
073
074    private static final Color PANEL_SEMITRANSPARENT = new Color(224, 236, 249, 230);
075    private static final Color PANEL_OPAQUE = new Color(224, 236, 249);
076
077    NotificationManager() {
078        queue = new LinkedList<>();
079        hideTimer = new Timer(Notification.TIME_DEFAULT, e -> this.stopHideTimer());
080        hideTimer.setRepeats(false);
081        pauseTimer = new Timer(pauseTime.get(), new PauseFinishedEvent());
082        pauseTimer.setRepeats(false);
083        unfreezeDelayTimer = new Timer(10, new UnfreezeEvent());
084        unfreezeDelayTimer.setRepeats(false);
085    }
086
087    /**
088     * Show the given notification
089     * @param note The note to show.
090     * @see Notification#show()
091     */
092    public void showNotification(Notification note) {
093        synchronized (queue) {
094            queue.add(note);
095            processQueue();
096        }
097    }
098
099    private void processQueue() {
100        if (running) return;
101
102        currentNotification = queue.poll();
103        if (currentNotification == null) return;
104
105        currentNotificationPanel = new NotificationPanel(currentNotification, new FreezeMouseListener(), e -> this.stopHideTimer());
106        currentNotificationPanel.validate();
107
108        int margin = 5;
109        JFrame parentWindow = (JFrame) Main.parent;
110        Dimension size = currentNotificationPanel.getPreferredSize();
111        if (parentWindow != null) {
112            int x;
113            int y;
114            if (Main.isDisplayingMapView() && Main.map.mapView.getHeight() > 0) {
115                MapView mv = Main.map.mapView;
116                Point mapViewPos = SwingUtilities.convertPoint(mv.getParent(), mv.getX(), mv.getY(), Main.parent);
117                x = mapViewPos.x + margin;
118                y = mapViewPos.y + mv.getHeight() - Main.map.statusLine.getHeight() - size.height - margin;
119            } else {
120                x = margin;
121                y = parentWindow.getHeight() - Main.toolbar.control.getSize().height - size.height - margin;
122            }
123            parentWindow.getLayeredPane().add(currentNotificationPanel, JLayeredPane.POPUP_LAYER, 0);
124
125            currentNotificationPanel.setLocation(x, y);
126        }
127        currentNotificationPanel.setSize(size);
128
129        currentNotificationPanel.setVisible(true);
130
131        running = true;
132        elapsedTime = 0;
133
134        startHideTimer();
135    }
136
137    private void startHideTimer() {
138        int remaining = (int) (currentNotification.getDuration() - elapsedTime);
139        if (remaining < 300) {
140            remaining = 300;
141        }
142        displayTimeStart = System.currentTimeMillis();
143        hideTimer.setInitialDelay(remaining);
144        hideTimer.restart();
145    }
146
147    private void stopHideTimer() {
148        hideTimer.stop();
149        if (currentNotificationPanel != null) {
150            currentNotificationPanel.setVisible(false);
151            JFrame parent = (JFrame) Main.parent;
152            if (parent != null) {
153                parent.getLayeredPane().remove(currentNotificationPanel);
154            }
155            currentNotificationPanel = null;
156        }
157        pauseTimer.restart();
158    }
159
160    private class PauseFinishedEvent implements ActionListener {
161
162        @Override
163        public void actionPerformed(ActionEvent e) {
164            synchronized (queue) {
165                running = false;
166                processQueue();
167            }
168        }
169    }
170
171    private class UnfreezeEvent implements ActionListener {
172
173        @Override
174        public void actionPerformed(ActionEvent e) {
175            if (currentNotificationPanel != null) {
176                currentNotificationPanel.setNotificationBackground(PANEL_SEMITRANSPARENT);
177                currentNotificationPanel.repaint();
178            }
179            startHideTimer();
180        }
181    }
182
183    private static class NotificationPanel extends JPanel {
184
185        static final class ShowNoteHelpAction extends AbstractAction {
186            private final Notification note;
187
188            ShowNoteHelpAction(Notification note) {
189                this.note = note;
190            }
191
192            @Override
193            public void actionPerformed(ActionEvent e) {
194                SwingUtilities.invokeLater(() -> HelpBrowser.setUrlForHelpTopic(note.getHelpTopic()));
195            }
196        }
197
198        private JPanel innerPanel;
199
200        NotificationPanel(Notification note, MouseListener freeze, ActionListener hideListener) {
201            setVisible(false);
202            build(note, freeze, hideListener);
203        }
204
205        public void setNotificationBackground(Color c) {
206            innerPanel.setBackground(c);
207        }
208
209        private void build(final Notification note, MouseListener freeze, ActionListener hideListener) {
210            JButton btnClose = new JButton();
211            btnClose.addActionListener(hideListener);
212            btnClose.setIcon(ImageProvider.get("misc", "grey_x"));
213            btnClose.setPreferredSize(new Dimension(50, 50));
214            btnClose.setMargin(new Insets(0, 0, 1, 1));
215            btnClose.setContentAreaFilled(false);
216            // put it in JToolBar to get a better appearance
217            JToolBar tbClose = new JToolBar();
218            tbClose.setFloatable(false);
219            tbClose.setBorderPainted(false);
220            tbClose.setOpaque(false);
221            tbClose.add(btnClose);
222
223            JToolBar tbHelp = null;
224            if (note.getHelpTopic() != null) {
225                JButton btnHelp = new JButton(tr("Help"));
226                btnHelp.setIcon(ImageProvider.get("help"));
227                btnHelp.setToolTipText(tr("Show help information"));
228                HelpUtil.setHelpContext(btnHelp, note.getHelpTopic());
229                btnHelp.addActionListener(new ShowNoteHelpAction(note));
230                btnHelp.setOpaque(false);
231                tbHelp = new JToolBar();
232                tbHelp.setFloatable(false);
233                tbHelp.setBorderPainted(false);
234                tbHelp.setOpaque(false);
235                tbHelp.add(btnHelp);
236            }
237
238            setOpaque(false);
239            innerPanel = new RoundedPanel();
240            innerPanel.setBackground(PANEL_SEMITRANSPARENT);
241            innerPanel.setForeground(Color.BLACK);
242
243            GroupLayout layout = new GroupLayout(innerPanel);
244            innerPanel.setLayout(layout);
245            layout.setAutoCreateGaps(true);
246            layout.setAutoCreateContainerGaps(true);
247
248            innerPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
249            add(innerPanel);
250
251            JLabel icon = null;
252            if (note.getIcon() != null) {
253                icon = new JLabel(note.getIcon());
254            }
255            Component content = note.getContent();
256            GroupLayout.SequentialGroup hgroup = layout.createSequentialGroup();
257            if (icon != null) {
258                hgroup.addComponent(icon);
259            }
260            if (tbHelp != null) {
261                hgroup.addGroup(layout.createParallelGroup(GroupLayout.Alignment.TRAILING)
262                        .addComponent(content)
263                        .addComponent(tbHelp)
264                );
265            } else {
266                hgroup.addComponent(content);
267            }
268            hgroup.addComponent(tbClose);
269            GroupLayout.ParallelGroup vgroup = layout.createParallelGroup();
270            if (icon != null) {
271                vgroup.addComponent(icon);
272            }
273            vgroup.addComponent(content);
274            vgroup.addComponent(tbClose);
275            layout.setHorizontalGroup(hgroup);
276
277            if (tbHelp != null) {
278                layout.setVerticalGroup(layout.createSequentialGroup()
279                        .addGroup(vgroup)
280                        .addComponent(tbHelp)
281                );
282            } else {
283                layout.setVerticalGroup(vgroup);
284            }
285
286            /*
287             * The timer stops when the mouse cursor is above the panel.
288             *
289             * This is not straightforward, because the JPanel will get a
290             * mouseExited event when the cursor moves on top of the JButton
291             * inside the panel.
292             *
293             * The current hacky solution is to register the freeze MouseListener
294             * not only to the panel, but to all the components inside the panel.
295             *
296             * Moving the mouse cursor from one component to the next would
297             * cause some flickering (timer is started and stopped for a fraction
298             * of a second, background color is switched twice), so there is
299             * a tiny delay before the timer really resumes.
300             */
301            addMouseListenerToAllChildComponents(this, freeze);
302        }
303
304        private static void addMouseListenerToAllChildComponents(Component comp, MouseListener listener) {
305            comp.addMouseListener(listener);
306            if (comp instanceof Container) {
307                for (Component c: ((Container) comp).getComponents()) {
308                    addMouseListenerToAllChildComponents(c, listener);
309                }
310            }
311        }
312    }
313
314    class FreezeMouseListener extends MouseAdapter {
315        @Override
316        public void mouseEntered(MouseEvent e) {
317            if (unfreezeDelayTimer.isRunning()) {
318                unfreezeDelayTimer.stop();
319            } else {
320                hideTimer.stop();
321                elapsedTime += System.currentTimeMillis() - displayTimeStart;
322                currentNotificationPanel.setNotificationBackground(PANEL_OPAQUE);
323                currentNotificationPanel.repaint();
324            }
325        }
326
327        @Override
328        public void mouseExited(MouseEvent e) {
329            unfreezeDelayTimer.restart();
330        }
331    }
332
333    /**
334     * A panel with rounded edges and line border.
335     */
336    public static class RoundedPanel extends JPanel {
337
338        RoundedPanel() {
339            super();
340            setOpaque(false);
341        }
342
343        @Override
344        protected void paintComponent(Graphics graphics) {
345            Graphics2D g = (Graphics2D) graphics;
346            g.setRenderingHint(
347                    RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
348            g.setColor(getBackground());
349            float lineWidth = 1.4f;
350            Shape rect = new RoundRectangle2D.Double(
351                    lineWidth/2d + getInsets().left,
352                    lineWidth/2d + getInsets().top,
353                    getWidth() - lineWidth/2d - getInsets().left - getInsets().right,
354                    getHeight() - lineWidth/2d - getInsets().top - getInsets().bottom,
355                    20, 20);
356
357            g.fill(rect);
358            g.setColor(getForeground());
359            g.setStroke(new BasicStroke(lineWidth));
360            g.draw(rect);
361            super.paintComponent(graphics);
362        }
363    }
364
365    public static synchronized NotificationManager getInstance() {
366        if (instance == null) {
367            instance = new NotificationManager();
368        }
369        return instance;
370    }
371}