source: josm/trunk/src/org/openstreetmap/josm/gui/NotificationManager.java@ 18077

Last change on this file since 18077 was 17901, checked in by simon04, 3 years ago

fix #20862 - Notifications: Help button icon too big and lots of empty space

  • Property svn:eol-style set to native
File size: 14.4 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.gui;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5
6import java.awt.BasicStroke;
7import java.awt.Color;
8import java.awt.Component;
9import java.awt.Container;
10import java.awt.Dimension;
11import java.awt.Graphics;
12import java.awt.Graphics2D;
13import java.awt.Insets;
14import java.awt.Point;
15import java.awt.RenderingHints;
16import java.awt.Shape;
17import java.awt.event.ActionEvent;
18import java.awt.event.ActionListener;
19import java.awt.event.MouseAdapter;
20import java.awt.event.MouseEvent;
21import java.awt.event.MouseListener;
22import java.awt.geom.RoundRectangle2D;
23import java.util.Deque;
24import java.util.LinkedList;
25import java.util.Objects;
26
27import javax.swing.AbstractAction;
28import javax.swing.BorderFactory;
29import javax.swing.GroupLayout;
30import javax.swing.JButton;
31import javax.swing.JFrame;
32import javax.swing.JLabel;
33import javax.swing.JLayeredPane;
34import javax.swing.JPanel;
35import javax.swing.JToolBar;
36import javax.swing.SwingUtilities;
37import javax.swing.Timer;
38
39import org.openstreetmap.josm.data.preferences.IntegerProperty;
40import org.openstreetmap.josm.gui.help.HelpBrowser;
41import org.openstreetmap.josm.gui.help.HelpUtil;
42import org.openstreetmap.josm.gui.util.GuiHelper;
43import org.openstreetmap.josm.tools.ImageProvider;
44import org.openstreetmap.josm.tools.Logging;
45
46/**
47 * Manages {@link Notification}s, i.e. displays them on screen.
48 *
49 * Don't use this class directly, but use {@link Notification#show()}.
50 *
51 * If multiple messages are sent in a short period of time, they are put in
52 * a queue and displayed one after the other.
53 *
54 * The user can stop the timer (freeze the message) by moving the mouse cursor
55 * above the panel. As a visual cue, the background color changes from
56 * semi-transparent to opaque while the timer is frozen.
57 */
58class NotificationManager {
59
60 private final Timer hideTimer; // started when message is shown, responsible for hiding the message
61 private final Timer pauseTimer; // makes sure, there is a small pause between two consecutive messages
62 private final Timer unfreezeDelayTimer; // tiny delay before resuming the timer when mouse cursor is moved off the panel
63 private boolean running;
64
65 private Notification currentNotification;
66 private NotificationPanel currentNotificationPanel;
67 private final Deque<Notification> queue;
68
69 private static final IntegerProperty pauseTime = new IntegerProperty("notification-default-pause-time-ms", 300); // milliseconds
70
71 private long displayTimeStart;
72 private long elapsedTime;
73
74 private static NotificationManager instance;
75
76 private static final Color PANEL_SEMITRANSPARENT = new Color(224, 236, 249, 230);
77 private static final Color PANEL_OPAQUE = new Color(224, 236, 249);
78
79 NotificationManager() {
80 queue = new LinkedList<>();
81 hideTimer = new Timer(Notification.TIME_DEFAULT, e -> this.stopHideTimer());
82 hideTimer.setRepeats(false);
83 pauseTimer = new Timer(pauseTime.get(), new PauseFinishedEvent());
84 pauseTimer.setRepeats(false);
85 unfreezeDelayTimer = new Timer(10, new UnfreezeEvent());
86 unfreezeDelayTimer.setRepeats(false);
87 }
88
89 /**
90 * Show the given notification (unless a duplicate notification is being shown at the moment or at the end of the queue)
91 * @param note The note to show.
92 * @see Notification#show()
93 */
94 void showNotification(Notification note) {
95 synchronized (queue) {
96 if (Objects.equals(note, currentNotification) || Objects.equals(note, queue.peekLast())) {
97 Logging.debug("Dropping duplicate notification {0}", note);
98 return;
99 }
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}
Note: See TracBrowser for help on using the repository browser.