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