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}