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