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