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.data.preferences.IntegerProperty;
039import org.openstreetmap.josm.gui.help.HelpBrowser;
040import org.openstreetmap.josm.gui.help.HelpUtil;
041import org.openstreetmap.josm.gui.util.GuiHelper;
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        GuiHelper.runInEDTAndWait(() -> {
106            currentNotificationPanel = new NotificationPanel(currentNotification, new FreezeMouseListener(), e -> this.stopHideTimer());
107            currentNotificationPanel.validate();
108
109            int margin = 5;
110            JFrame parentWindow = MainApplication.getMainFrame();
111            Dimension size = currentNotificationPanel.getPreferredSize();
112            if (parentWindow != null) {
113                int x;
114                int y;
115                MapFrame map = MainApplication.getMap();
116                if (MainApplication.isDisplayingMapView() && map.mapView.getHeight() > 0) {
117                    MapView mv = map.mapView;
118                    Point mapViewPos = SwingUtilities.convertPoint(mv.getParent(), mv.getX(), mv.getY(), MainApplication.getMainFrame());
119                    x = mapViewPos.x + margin;
120                    y = mapViewPos.y + mv.getHeight() - map.statusLine.getHeight() - size.height - margin;
121                } else {
122                    x = margin;
123                    y = parentWindow.getHeight() - MainApplication.getToolbar().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            currentNotificationPanel.setVisible(true);
131        });
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 void stopHideTimer() {
150        hideTimer.stop();
151        if (currentNotificationPanel != null) {
152            currentNotificationPanel.setVisible(false);
153            JFrame parent = MainApplication.getMainFrame();
154            if (parent != null) {
155                parent.getLayeredPane().remove(currentNotificationPanel);
156            }
157            currentNotificationPanel = null;
158        }
159        pauseTimer.restart();
160    }
161
162    private class PauseFinishedEvent implements ActionListener {
163
164        @Override
165        public void actionPerformed(ActionEvent e) {
166            synchronized (queue) {
167                running = false;
168                processQueue();
169            }
170        }
171    }
172
173    private class UnfreezeEvent implements ActionListener {
174
175        @Override
176        public void actionPerformed(ActionEvent e) {
177            if (currentNotificationPanel != null) {
178                currentNotificationPanel.setNotificationBackground(PANEL_SEMITRANSPARENT);
179                currentNotificationPanel.repaint();
180            }
181            startHideTimer();
182        }
183    }
184
185    private static class NotificationPanel extends JPanel {
186
187        static final class ShowNoteHelpAction extends AbstractAction {
188            private final Notification note;
189
190            ShowNoteHelpAction(Notification note) {
191                this.note = note;
192            }
193
194            @Override
195            public void actionPerformed(ActionEvent e) {
196                SwingUtilities.invokeLater(() -> HelpBrowser.setUrlForHelpTopic(note.getHelpTopic()));
197            }
198        }
199
200        private JPanel innerPanel;
201
202        NotificationPanel(Notification note, MouseListener freeze, ActionListener hideListener) {
203            setVisible(false);
204            build(note, freeze, hideListener);
205        }
206
207        public void setNotificationBackground(Color c) {
208            innerPanel.setBackground(c);
209        }
210
211        private void build(final Notification note, MouseListener freeze, ActionListener hideListener) {
212            JButton btnClose = new JButton();
213            btnClose.addActionListener(hideListener);
214            btnClose.setIcon(ImageProvider.get("misc", "grey_x"));
215            btnClose.setPreferredSize(new Dimension(50, 50));
216            btnClose.setMargin(new Insets(0, 0, 1, 1));
217            btnClose.setContentAreaFilled(false);
218            // put it in JToolBar to get a better appearance
219            JToolBar tbClose = new JToolBar();
220            tbClose.setFloatable(false);
221            tbClose.setBorderPainted(false);
222            tbClose.setOpaque(false);
223            tbClose.add(btnClose);
224
225            JToolBar tbHelp = null;
226            if (note.getHelpTopic() != null) {
227                JButton btnHelp = new JButton(tr("Help"));
228                btnHelp.setIcon(ImageProvider.get("help"));
229                btnHelp.setToolTipText(tr("Show help information"));
230                HelpUtil.setHelpContext(btnHelp, note.getHelpTopic());
231                btnHelp.addActionListener(new ShowNoteHelpAction(note));
232                btnHelp.setOpaque(false);
233                tbHelp = new JToolBar();
234                tbHelp.setFloatable(false);
235                tbHelp.setBorderPainted(false);
236                tbHelp.setOpaque(false);
237                tbHelp.add(btnHelp);
238            }
239
240            setOpaque(false);
241            innerPanel = new RoundedPanel();
242            innerPanel.setBackground(PANEL_SEMITRANSPARENT);
243            innerPanel.setForeground(Color.BLACK);
244
245            GroupLayout layout = new GroupLayout(innerPanel);
246            innerPanel.setLayout(layout);
247            layout.setAutoCreateGaps(true);
248            layout.setAutoCreateContainerGaps(true);
249
250            innerPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
251            add(innerPanel);
252
253            JLabel icon = null;
254            if (note.getIcon() != null) {
255                icon = new JLabel(note.getIcon());
256            }
257            Component content = note.getContent();
258            GroupLayout.SequentialGroup hgroup = layout.createSequentialGroup();
259            if (icon != null) {
260                hgroup.addComponent(icon);
261            }
262            if (tbHelp != null) {
263                hgroup.addGroup(layout.createParallelGroup(GroupLayout.Alignment.TRAILING)
264                        .addComponent(content)
265                        .addComponent(tbHelp)
266                );
267            } else {
268                hgroup.addComponent(content);
269            }
270            hgroup.addComponent(tbClose);
271            GroupLayout.ParallelGroup vgroup = layout.createParallelGroup();
272            if (icon != null) {
273                vgroup.addComponent(icon);
274            }
275            vgroup.addComponent(content);
276            vgroup.addComponent(tbClose);
277            layout.setHorizontalGroup(hgroup);
278
279            if (tbHelp != null) {
280                layout.setVerticalGroup(layout.createSequentialGroup()
281                        .addGroup(vgroup)
282                        .addComponent(tbHelp)
283                );
284            } else {
285                layout.setVerticalGroup(vgroup);
286            }
287
288            /*
289             * The timer stops when the mouse cursor is above the panel.
290             *
291             * This is not straightforward, because the JPanel will get a
292             * mouseExited event when the cursor moves on top of the JButton
293             * inside the panel.
294             *
295             * The current hacky solution is to register the freeze MouseListener
296             * not only to the panel, but to all the components inside the panel.
297             *
298             * Moving the mouse cursor from one component to the next would
299             * cause some flickering (timer is started and stopped for a fraction
300             * of a second, background color is switched twice), so there is
301             * a tiny delay before the timer really resumes.
302             */
303            addMouseListenerToAllChildComponents(this, freeze);
304        }
305
306        private static void addMouseListenerToAllChildComponents(Component comp, MouseListener listener) {
307            comp.addMouseListener(listener);
308            if (comp instanceof Container) {
309                for (Component c: ((Container) comp).getComponents()) {
310                    addMouseListenerToAllChildComponents(c, listener);
311                }
312            }
313        }
314    }
315
316    class FreezeMouseListener extends MouseAdapter {
317        @Override
318        public void mouseEntered(MouseEvent e) {
319            if (unfreezeDelayTimer.isRunning()) {
320                unfreezeDelayTimer.stop();
321            } else {
322                hideTimer.stop();
323                elapsedTime += System.currentTimeMillis() - displayTimeStart;
324                currentNotificationPanel.setNotificationBackground(PANEL_OPAQUE);
325                currentNotificationPanel.repaint();
326            }
327        }
328
329        @Override
330        public void mouseExited(MouseEvent e) {
331            unfreezeDelayTimer.restart();
332        }
333    }
334
335    /**
336     * A panel with rounded edges and line border.
337     */
338    public static class RoundedPanel extends JPanel {
339
340        RoundedPanel() {
341            super();
342            setOpaque(false);
343        }
344
345        @Override
346        protected void paintComponent(Graphics graphics) {
347            Graphics2D g = (Graphics2D) graphics;
348            g.setRenderingHint(
349                    RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
350            g.setColor(getBackground());
351            float lineWidth = 1.4f;
352            Shape rect = new RoundRectangle2D.Double(
353                    lineWidth/2d + getInsets().left,
354                    lineWidth/2d + getInsets().top,
355                    getWidth() - lineWidth/2d - getInsets().left - getInsets().right,
356                    getHeight() - lineWidth/2d - getInsets().top - getInsets().bottom,
357                    20, 20);
358
359            g.fill(rect);
360            g.setColor(getForeground());
361            g.setStroke(new BasicStroke(lineWidth));
362            g.draw(rect);
363            super.paintComponent(graphics);
364        }
365    }
366
367    public static synchronized NotificationManager getInstance() {
368        if (instance == null) {
369            instance = new NotificationManager();
370        }
371        return instance;
372    }
373}