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}