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        private JPanel innerPanel;
186
187        NotificationPanel(Notification note, MouseListener freeze, ActionListener hideListener) {
188            setVisible(false);
189            build(note, freeze, hideListener);
190        }
191
192        public void setNotificationBackground(Color c) {
193            innerPanel.setBackground(c);
194        }
195
196        private void build(final Notification note, MouseListener freeze, ActionListener hideListener) {
197            JButton btnClose = new JButton();
198            btnClose.addActionListener(hideListener);
199            btnClose.setIcon(ImageProvider.get("misc", "grey_x"));
200            btnClose.setPreferredSize(new Dimension(50, 50));
201            btnClose.setMargin(new Insets(0, 0, 1, 1));
202            btnClose.setContentAreaFilled(false);
203            // put it in JToolBar to get a better appearance
204            JToolBar tbClose = new JToolBar();
205            tbClose.setFloatable(false);
206            tbClose.setBorderPainted(false);
207            tbClose.setOpaque(false);
208            tbClose.add(btnClose);
209
210            JToolBar tbHelp = null;
211            if (note.getHelpTopic() != null) {
212                JButton btnHelp = new JButton(tr("Help"));
213                btnHelp.setIcon(ImageProvider.get("help"));
214                btnHelp.setToolTipText(tr("Show help information"));
215                HelpUtil.setHelpContext(btnHelp, note.getHelpTopic());
216                btnHelp.addActionListener(new AbstractAction() {
217                    @Override
218                    public void actionPerformed(ActionEvent e) {
219                        SwingUtilities.invokeLater(() -> HelpBrowser.setUrlForHelpTopic(note.getHelpTopic()));
220                    }
221                });
222                btnHelp.setOpaque(false);
223                tbHelp = new JToolBar();
224                tbHelp.setFloatable(false);
225                tbHelp.setBorderPainted(false);
226                tbHelp.setOpaque(false);
227                tbHelp.add(btnHelp);
228            }
229
230            setOpaque(false);
231            innerPanel = new RoundedPanel();
232            innerPanel.setBackground(PANEL_SEMITRANSPARENT);
233            innerPanel.setForeground(Color.BLACK);
234
235            GroupLayout layout = new GroupLayout(innerPanel);
236            innerPanel.setLayout(layout);
237            layout.setAutoCreateGaps(true);
238            layout.setAutoCreateContainerGaps(true);
239
240            innerPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
241            add(innerPanel);
242
243            JLabel icon = null;
244            if (note.getIcon() != null) {
245                icon = new JLabel(note.getIcon());
246            }
247            Component content = note.getContent();
248            GroupLayout.SequentialGroup hgroup = layout.createSequentialGroup();
249            if (icon != null) {
250                hgroup.addComponent(icon);
251            }
252            if (tbHelp != null) {
253                hgroup.addGroup(layout.createParallelGroup(GroupLayout.Alignment.TRAILING)
254                        .addComponent(content)
255                        .addComponent(tbHelp)
256                );
257            } else {
258                hgroup.addComponent(content);
259            }
260            hgroup.addComponent(tbClose);
261            GroupLayout.ParallelGroup vgroup = layout.createParallelGroup();
262            if (icon != null) {
263                vgroup.addComponent(icon);
264            }
265            vgroup.addComponent(content);
266            vgroup.addComponent(tbClose);
267            layout.setHorizontalGroup(hgroup);
268
269            if (tbHelp != null) {
270                layout.setVerticalGroup(layout.createSequentialGroup()
271                        .addGroup(vgroup)
272                        .addComponent(tbHelp)
273                );
274            } else {
275                layout.setVerticalGroup(vgroup);
276            }
277
278            /*
279             * The timer stops when the mouse cursor is above the panel.
280             *
281             * This is not straightforward, because the JPanel will get a
282             * mouseExited event when the cursor moves on top of the JButton
283             * inside the panel.
284             *
285             * The current hacky solution is to register the freeze MouseListener
286             * not only to the panel, but to all the components inside the panel.
287             *
288             * Moving the mouse cursor from one component to the next would
289             * cause some flickering (timer is started and stopped for a fraction
290             * of a second, background color is switched twice), so there is
291             * a tiny delay before the timer really resumes.
292             */
293            addMouseListenerToAllChildComponents(this, freeze);
294        }
295
296        private static void addMouseListenerToAllChildComponents(Component comp, MouseListener listener) {
297            comp.addMouseListener(listener);
298            if (comp instanceof Container) {
299                for (Component c: ((Container) comp).getComponents()) {
300                    addMouseListenerToAllChildComponents(c, listener);
301                }
302            }
303        }
304    }
305
306    class FreezeMouseListener extends MouseAdapter {
307        @Override
308        public void mouseEntered(MouseEvent e) {
309            if (unfreezeDelayTimer.isRunning()) {
310                unfreezeDelayTimer.stop();
311            } else {
312                hideTimer.stop();
313                elapsedTime += System.currentTimeMillis() - displayTimeStart;
314                currentNotificationPanel.setNotificationBackground(PANEL_OPAQUE);
315                currentNotificationPanel.repaint();
316            }
317        }
318
319        @Override
320        public void mouseExited(MouseEvent e) {
321            unfreezeDelayTimer.restart();
322        }
323    }
324
325    /**
326     * A panel with rounded edges and line border.
327     */
328    public static class RoundedPanel extends JPanel {
329
330        RoundedPanel() {
331            super();
332            setOpaque(false);
333        }
334
335        @Override
336        protected void paintComponent(Graphics graphics) {
337            Graphics2D g = (Graphics2D) graphics;
338            g.setRenderingHint(
339                    RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
340            g.setColor(getBackground());
341            float lineWidth = 1.4f;
342            Shape rect = new RoundRectangle2D.Double(
343                    lineWidth/2d + getInsets().left,
344                    lineWidth/2d + getInsets().top,
345                    getWidth() - lineWidth/2d - getInsets().left - getInsets().right,
346                    getHeight() - lineWidth/2d - getInsets().top - getInsets().bottom,
347                    20, 20);
348
349            g.fill(rect);
350            g.setColor(getForeground());
351            g.setStroke(new BasicStroke(lineWidth));
352            g.draw(rect);
353            super.paintComponent(graphics);
354        }
355    }
356
357    public static synchronized NotificationManager getInstance() {
358        if (instance == null) {
359            instance = new NotificationManager();
360        }
361        return instance;
362    }
363}