001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui; 003 004import static org.openstreetmap.josm.gui.help.HelpUtil.ht; 005import static org.openstreetmap.josm.tools.I18n.marktr; 006import static org.openstreetmap.josm.tools.I18n.tr; 007 008import java.awt.AWTEvent; 009import java.awt.Color; 010import java.awt.Component; 011import java.awt.Cursor; 012import java.awt.Dimension; 013import java.awt.EventQueue; 014import java.awt.Font; 015import java.awt.GridBagLayout; 016import java.awt.Point; 017import java.awt.SystemColor; 018import java.awt.Toolkit; 019import java.awt.event.AWTEventListener; 020import java.awt.event.ActionEvent; 021import java.awt.event.ComponentAdapter; 022import java.awt.event.ComponentEvent; 023import java.awt.event.InputEvent; 024import java.awt.event.KeyAdapter; 025import java.awt.event.KeyEvent; 026import java.awt.event.MouseAdapter; 027import java.awt.event.MouseEvent; 028import java.awt.event.MouseListener; 029import java.awt.event.MouseMotionListener; 030import java.lang.reflect.InvocationTargetException; 031import java.text.DecimalFormat; 032import java.util.ArrayList; 033import java.util.Collection; 034import java.util.ConcurrentModificationException; 035import java.util.List; 036import java.util.Objects; 037import java.util.TreeSet; 038import java.util.concurrent.BlockingQueue; 039import java.util.concurrent.LinkedBlockingQueue; 040 041import javax.swing.AbstractAction; 042import javax.swing.BorderFactory; 043import javax.swing.JCheckBoxMenuItem; 044import javax.swing.JLabel; 045import javax.swing.JMenuItem; 046import javax.swing.JPanel; 047import javax.swing.JPopupMenu; 048import javax.swing.JProgressBar; 049import javax.swing.JScrollPane; 050import javax.swing.JSeparator; 051import javax.swing.Popup; 052import javax.swing.PopupFactory; 053import javax.swing.UIManager; 054import javax.swing.event.PopupMenuEvent; 055import javax.swing.event.PopupMenuListener; 056 057import org.openstreetmap.josm.Main; 058import org.openstreetmap.josm.data.Preferences.PreferenceChangeEvent; 059import org.openstreetmap.josm.data.Preferences.PreferenceChangedListener; 060import org.openstreetmap.josm.data.SystemOfMeasurement; 061import org.openstreetmap.josm.data.SystemOfMeasurement.SoMChangeListener; 062import org.openstreetmap.josm.data.coor.CoordinateFormat; 063import org.openstreetmap.josm.data.coor.LatLon; 064import org.openstreetmap.josm.data.osm.DataSet; 065import org.openstreetmap.josm.data.osm.OsmPrimitive; 066import org.openstreetmap.josm.data.osm.Way; 067import org.openstreetmap.josm.data.preferences.AbstractProperty; 068import org.openstreetmap.josm.data.preferences.BooleanProperty; 069import org.openstreetmap.josm.data.preferences.ColorProperty; 070import org.openstreetmap.josm.data.preferences.DoubleProperty; 071import org.openstreetmap.josm.gui.help.Helpful; 072import org.openstreetmap.josm.gui.preferences.projection.ProjectionPreference; 073import org.openstreetmap.josm.gui.progress.PleaseWaitProgressMonitor; 074import org.openstreetmap.josm.gui.progress.PleaseWaitProgressMonitor.ProgressMonitorDialog; 075import org.openstreetmap.josm.gui.util.GuiHelper; 076import org.openstreetmap.josm.gui.widgets.ImageLabel; 077import org.openstreetmap.josm.gui.widgets.JosmTextField; 078import org.openstreetmap.josm.tools.Destroyable; 079import org.openstreetmap.josm.tools.GBC; 080import org.openstreetmap.josm.tools.ImageProvider; 081 082/** 083 * A component that manages some status information display about the map. 084 * It keeps a status line below the map up to date and displays some tooltip 085 * information if the user hold the mouse long enough at some point. 086 * 087 * All this is done in background to not disturb other processes. 088 * 089 * The background thread does not alter any data of the map (read only thread). 090 * Also it is rather fail safe. In case of some error in the data, it just does 091 * nothing instead of whining and complaining. 092 * 093 * @author imi 094 */ 095public final class MapStatus extends JPanel implements Helpful, Destroyable, PreferenceChangedListener, SoMChangeListener { 096 097 private final DecimalFormat DECIMAL_FORMAT = new DecimalFormat(Main.pref.get("statusbar.decimal-format", "0.0")); 098 private static final AbstractProperty<Double> DISTANCE_THRESHOLD = new DoubleProperty("statusbar.distance-threshold", 0.01).cached(); 099 100 private static final AbstractProperty<Boolean> SHOW_ID = new BooleanProperty("osm-primitives.showid", false); 101 102 /** 103 * Property for map status background color. 104 * @since 6789 105 */ 106 public static final ColorProperty PROP_BACKGROUND_COLOR = new ColorProperty( 107 marktr("Status bar background"), "#b8cfe5"); 108 109 /** 110 * Property for map status background color (active state). 111 * @since 6789 112 */ 113 public static final ColorProperty PROP_ACTIVE_BACKGROUND_COLOR = new ColorProperty( 114 marktr("Status bar background: active"), "#aaff5e"); 115 116 /** 117 * Property for map status foreground color. 118 * @since 6789 119 */ 120 public static final ColorProperty PROP_FOREGROUND_COLOR = new ColorProperty( 121 marktr("Status bar foreground"), Color.black); 122 123 /** 124 * Property for map status foreground color (active state). 125 * @since 6789 126 */ 127 public static final ColorProperty PROP_ACTIVE_FOREGROUND_COLOR = new ColorProperty( 128 marktr("Status bar foreground: active"), Color.black); 129 130 /** 131 * The MapView this status belongs to. 132 */ 133 private final MapView mv; 134 private final transient Collector collector; 135 136 public class BackgroundProgressMonitor implements ProgressMonitorDialog { 137 138 private String title; 139 private String customText; 140 141 private void updateText() { 142 if (customText != null && !customText.isEmpty()) { 143 progressBar.setToolTipText(tr("{0} ({1})", title, customText)); 144 } else { 145 progressBar.setToolTipText(title); 146 } 147 } 148 149 @Override 150 public void setVisible(boolean visible) { 151 progressBar.setVisible(visible); 152 } 153 154 @Override 155 public void updateProgress(int progress) { 156 progressBar.setValue(progress); 157 progressBar.repaint(); 158 MapStatus.this.doLayout(); 159 } 160 161 @Override 162 public void setCustomText(String text) { 163 this.customText = text; 164 updateText(); 165 } 166 167 @Override 168 public void setCurrentAction(String text) { 169 this.title = text; 170 updateText(); 171 } 172 173 @Override 174 public void setIndeterminate(boolean newValue) { 175 UIManager.put("ProgressBar.cycleTime", UIManager.getInt("ProgressBar.repaintInterval") * 100); 176 progressBar.setIndeterminate(newValue); 177 } 178 179 @Override 180 public void appendLogMessage(String message) { 181 if (message != null && !message.isEmpty()) { 182 Main.info("appendLogMessage not implemented for background tasks. Message was: " + message); 183 } 184 } 185 186 } 187 188 /** The {@link CoordinateFormat} set in the previous update */ 189 private transient CoordinateFormat previousCoordinateFormat; 190 private final ImageLabel latText = new ImageLabel("lat", 191 null, LatLon.SOUTH_POLE.latToString(CoordinateFormat.DEGREES_MINUTES_SECONDS).length(), PROP_BACKGROUND_COLOR.get()); 192 private final ImageLabel lonText = new ImageLabel("lon", 193 null, new LatLon(0, 180).lonToString(CoordinateFormat.DEGREES_MINUTES_SECONDS).length(), PROP_BACKGROUND_COLOR.get()); 194 private final ImageLabel headingText = new ImageLabel("heading", 195 tr("The (compass) heading of the line segment being drawn."), 196 DECIMAL_FORMAT.format(360).length() + 1, PROP_BACKGROUND_COLOR.get()); 197 private final ImageLabel angleText = new ImageLabel("angle", 198 tr("The angle between the previous and the current way segment."), 199 DECIMAL_FORMAT.format(360).length() + 1, PROP_BACKGROUND_COLOR.get()); 200 private final ImageLabel distText = new ImageLabel("dist", 201 tr("The length of the new way segment being drawn."), 10, PROP_BACKGROUND_COLOR.get()); 202 private final ImageLabel nameText = new ImageLabel("name", 203 tr("The name of the object at the mouse pointer."), getNameLabelCharacterCount(Main.parent), PROP_BACKGROUND_COLOR.get()); 204 private final JosmTextField helpText = new JosmTextField(); 205 private final JProgressBar progressBar = new JProgressBar(); 206 private final transient ComponentAdapter mvComponentAdapter; 207 public final transient BackgroundProgressMonitor progressMonitor = new BackgroundProgressMonitor(); 208 209 // Distance value displayed in distText, stored if refresh needed after a change of system of measurement 210 private double distValue; 211 212 // Determines if angle panel is enabled or not 213 private boolean angleEnabled; 214 215 /** 216 * This is the thread that runs in the background and collects the information displayed. 217 * It gets destroyed by destroy() when the MapFrame itself is destroyed. 218 */ 219 private final transient Thread thread; 220 221 private final transient List<StatusTextHistory> statusText = new ArrayList<>(); 222 223 protected static final class StatusTextHistory { 224 private final Object id; 225 private final String text; 226 227 StatusTextHistory(Object id, String text) { 228 this.id = id; 229 this.text = text; 230 } 231 232 @Override 233 public boolean equals(Object obj) { 234 return obj instanceof StatusTextHistory && ((StatusTextHistory) obj).id == id; 235 } 236 237 @Override 238 public int hashCode() { 239 return System.identityHashCode(id); 240 } 241 } 242 243 /** 244 * The collector class that waits for notification and then update the display objects. 245 * 246 * @author imi 247 */ 248 private final class Collector implements Runnable { 249 private final class CollectorWorker implements Runnable { 250 private final MouseState ms; 251 252 private CollectorWorker(MouseState ms) { 253 this.ms = ms; 254 } 255 256 @Override 257 public void run() { 258 // Freeze display when holding down CTRL 259 if ((ms.modifiers & MouseEvent.CTRL_DOWN_MASK) != 0) { 260 // update the information popup's labels though, because the selection might have changed from the outside 261 popupUpdateLabels(); 262 return; 263 } 264 265 // This try/catch is a hack to stop the flooding bug reports about this. 266 // The exception needed to handle with in the first place, means that this 267 // access to the data need to be restarted, if the main thread modifies the data. 268 DataSet ds = null; 269 // The popup != null check is required because a left-click produces several events as well, 270 // which would make this variable true. Of course we only want the popup to show 271 // if the middle mouse button has been pressed in the first place 272 boolean mouseNotMoved = oldMousePos != null && oldMousePos.equals(ms.mousePos); 273 boolean isAtOldPosition = mouseNotMoved && popup != null; 274 boolean middleMouseDown = (ms.modifiers & MouseEvent.BUTTON2_DOWN_MASK) != 0; 275 try { 276 ds = mv.getLayerManager().getEditDataSet(); 277 if (ds != null) { 278 // This is not perfect, if current dataset was changed during execution, the lock would be useless 279 if (isAtOldPosition && middleMouseDown) { 280 // Write lock is necessary when selecting in popupCycleSelection 281 // locks can not be upgraded -> if do read lock here and write lock later 282 // (in OsmPrimitive.updateFlags) then always occurs deadlock (#5814) 283 ds.beginUpdate(); 284 } else { 285 ds.getReadLock().lock(); 286 } 287 } 288 289 // Set the text label in the bottom status bar 290 // "if mouse moved only" was added to stop heap growing 291 if (!mouseNotMoved) { 292 statusBarElementUpdate(ms); 293 } 294 295 // Popup Information 296 // display them if the middle mouse button is pressed and keep them until the mouse is moved 297 if (middleMouseDown || isAtOldPosition) { 298 Collection<OsmPrimitive> osms = mv.getAllNearest(ms.mousePos, OsmPrimitive::isSelectable); 299 300 final JPanel c = new JPanel(new GridBagLayout()); 301 final JLabel lbl = new JLabel( 302 "<html>"+tr("Middle click again to cycle through.<br>"+ 303 "Hold CTRL to select directly from this list with the mouse.<hr>")+"</html>", 304 null, 305 JLabel.HORIZONTAL 306 ); 307 lbl.setHorizontalAlignment(JLabel.LEFT); 308 c.add(lbl, GBC.eol().insets(2, 0, 2, 0)); 309 310 // Only cycle if the mouse has not been moved and the middle mouse button has been pressed at least 311 // twice (the reason for this is the popup != null check for isAtOldPosition, see above. 312 // This is a nice side effect though, because it does not change selection of the first middle click) 313 if (isAtOldPosition && middleMouseDown) { 314 // Hand down mouse modifiers so the SHIFT mod can be handled correctly (see function) 315 popupCycleSelection(osms, ms.modifiers); 316 } 317 318 // These labels may need to be updated from the outside so collect them 319 List<JLabel> lbls = new ArrayList<>(osms.size()); 320 for (final OsmPrimitive osm : osms) { 321 JLabel l = popupBuildPrimitiveLabels(osm); 322 lbls.add(l); 323 c.add(l, GBC.eol().fill(GBC.HORIZONTAL).insets(2, 0, 2, 2)); 324 } 325 326 popupShowPopup(popupCreatePopup(c, ms), lbls); 327 } else { 328 popupHidePopup(); 329 } 330 331 oldMousePos = ms.mousePos; 332 } catch (ConcurrentModificationException x) { 333 Main.warn(x); 334 } finally { 335 if (ds != null) { 336 if (isAtOldPosition && middleMouseDown) { 337 ds.endUpdate(); 338 } else { 339 ds.getReadLock().unlock(); 340 } 341 } 342 } 343 } 344 } 345 346 /** 347 * the mouse position of the previous iteration. This is used to show 348 * the popup until the cursor is moved. 349 */ 350 private Point oldMousePos; 351 /** 352 * Contains the labels that are currently shown in the information 353 * popup 354 */ 355 private List<JLabel> popupLabels; 356 /** 357 * The popup displayed to show additional information 358 */ 359 private Popup popup; 360 361 private final MapFrame parent; 362 363 private final BlockingQueue<MouseState> incomingMouseState = new LinkedBlockingQueue<>(); 364 365 private Point lastMousePos; 366 367 Collector(MapFrame parent) { 368 this.parent = parent; 369 } 370 371 /** 372 * Execution function for the Collector. 373 */ 374 @Override 375 public void run() { 376 registerListeners(); 377 try { 378 for (;;) { 379 try { 380 final MouseState ms = incomingMouseState.take(); 381 if (parent != Main.map) 382 return; // exit, if new parent. 383 384 // Do nothing, if required data is missing 385 if (ms.mousePos == null || mv.getCenter() == null) { 386 continue; 387 } 388 389 EventQueue.invokeAndWait(new CollectorWorker(ms)); 390 } catch (InterruptedException e) { 391 // Occurs frequently during JOSM shutdown, log set to trace only 392 Main.trace("InterruptedException in "+MapStatus.class.getSimpleName()); 393 } catch (InvocationTargetException e) { 394 Main.warn(e); 395 } 396 } 397 } finally { 398 unregisterListeners(); 399 } 400 } 401 402 /** 403 * Creates a popup for the given content next to the cursor. Tries to 404 * keep the popup on screen and shows a vertical scrollbar, if the 405 * screen is too small. 406 * @param content popup content 407 * @param ms mouse state 408 * @return popup 409 */ 410 private Popup popupCreatePopup(Component content, MouseState ms) { 411 Point p = mv.getLocationOnScreen(); 412 Dimension scrn = GuiHelper.getScreenSize(); 413 414 // Create a JScrollPane around the content, in case there's not enough space 415 JScrollPane sp = GuiHelper.embedInVerticalScrollPane(content); 416 sp.setBorder(BorderFactory.createRaisedBevelBorder()); 417 // Implement max-size content-independent 418 Dimension prefsize = sp.getPreferredSize(); 419 int w = Math.min(prefsize.width, Math.min(800, (scrn.width/2) - 16)); 420 int h = Math.min(prefsize.height, scrn.height - 10); 421 sp.setPreferredSize(new Dimension(w, h)); 422 423 int xPos = p.x + ms.mousePos.x + 16; 424 // Display the popup to the left of the cursor if it would be cut 425 // off on its right, but only if more space is available 426 if (xPos + w > scrn.width && xPos > scrn.width/2) { 427 xPos = p.x + ms.mousePos.x - 4 - w; 428 } 429 int yPos = p.y + ms.mousePos.y + 16; 430 // Move the popup up if it would be cut off at its bottom but do not 431 // move it off screen on the top 432 if (yPos + h > scrn.height - 5) { 433 yPos = Math.max(5, scrn.height - h - 5); 434 } 435 436 PopupFactory pf = PopupFactory.getSharedInstance(); 437 return pf.getPopup(mv, sp, xPos, yPos); 438 } 439 440 /** 441 * Calls this to update the element that is shown in the statusbar 442 * @param ms mouse state 443 */ 444 private void statusBarElementUpdate(MouseState ms) { 445 final OsmPrimitive osmNearest = mv.getNearestNodeOrWay(ms.mousePos, OsmPrimitive::isUsable, false); 446 if (osmNearest != null) { 447 nameText.setText(osmNearest.getDisplayName(DefaultNameFormatter.getInstance())); 448 } else { 449 nameText.setText(tr("(no object)")); 450 } 451 } 452 453 /** 454 * Call this with a set of primitives to cycle through them. Method 455 * will automatically select the next item and update the map 456 * @param osms primitives to cycle through 457 * @param mods modifiers (i.e. control keys) 458 */ 459 private void popupCycleSelection(Collection<OsmPrimitive> osms, int mods) { 460 DataSet ds = Main.getLayerManager().getEditDataSet(); 461 // Find some items that are required for cycling through 462 OsmPrimitive firstItem = null; 463 OsmPrimitive firstSelected = null; 464 OsmPrimitive nextSelected = null; 465 for (final OsmPrimitive osm : osms) { 466 if (firstItem == null) { 467 firstItem = osm; 468 } 469 if (firstSelected != null && nextSelected == null) { 470 nextSelected = osm; 471 } 472 if (firstSelected == null && ds.isSelected(osm)) { 473 firstSelected = osm; 474 } 475 } 476 477 // Clear previous selection if SHIFT (add to selection) is not 478 // pressed. Cannot use "setSelected()" because it will cause a 479 // fireSelectionChanged event which is unnecessary at this point. 480 if ((mods & MouseEvent.SHIFT_DOWN_MASK) == 0) { 481 ds.clearSelection(); 482 } 483 484 // This will cycle through the available items. 485 if (firstSelected != null) { 486 ds.clearSelection(firstSelected); 487 if (nextSelected != null) { 488 ds.addSelected(nextSelected); 489 } 490 } else if (firstItem != null) { 491 ds.addSelected(firstItem); 492 } 493 } 494 495 /** 496 * Tries to hide the given popup 497 */ 498 private void popupHidePopup() { 499 popupLabels = null; 500 if (popup == null) 501 return; 502 final Popup staticPopup = popup; 503 popup = null; 504 EventQueue.invokeLater(staticPopup::hide); 505 } 506 507 /** 508 * Tries to show the given popup, can be hidden using {@link #popupHidePopup} 509 * If an old popup exists, it will be automatically hidden 510 * @param newPopup popup to show 511 * @param lbls lables to show (see {@link #popupLabels}) 512 */ 513 private void popupShowPopup(Popup newPopup, List<JLabel> lbls) { 514 final Popup staticPopup = newPopup; 515 if (this.popup != null) { 516 // If an old popup exists, remove it when the new popup has been drawn to keep flickering to a minimum 517 final Popup staticOldPopup = this.popup; 518 EventQueue.invokeLater(() -> { 519 staticPopup.show(); 520 staticOldPopup.hide(); 521 }); 522 } else { 523 // There is no old popup 524 EventQueue.invokeLater(staticPopup::show); 525 } 526 this.popupLabels = lbls; 527 this.popup = newPopup; 528 } 529 530 /** 531 * This method should be called if the selection may have changed from 532 * outside of this class. This is the case when CTRL is pressed and the 533 * user clicks on the map instead of the popup. 534 */ 535 private void popupUpdateLabels() { 536 if (this.popup == null || this.popupLabels == null) 537 return; 538 for (JLabel l : this.popupLabels) { 539 l.validate(); 540 } 541 } 542 543 /** 544 * Sets the colors for the given label depending on the selected status of 545 * the given OsmPrimitive 546 * 547 * @param lbl The label to color 548 * @param osm The primitive to derive the colors from 549 */ 550 private void popupSetLabelColors(JLabel lbl, OsmPrimitive osm) { 551 DataSet ds = Main.getLayerManager().getEditDataSet(); 552 if (ds.isSelected(osm)) { 553 lbl.setBackground(SystemColor.textHighlight); 554 lbl.setForeground(SystemColor.textHighlightText); 555 } else { 556 lbl.setBackground(SystemColor.control); 557 lbl.setForeground(SystemColor.controlText); 558 } 559 } 560 561 /** 562 * Builds the labels with all necessary listeners for the info popup for the 563 * given OsmPrimitive 564 * @param osm The primitive to create the label for 565 * @return labels for info popup 566 */ 567 private JLabel popupBuildPrimitiveLabels(final OsmPrimitive osm) { 568 final StringBuilder text = new StringBuilder(32); 569 String name = osm.getDisplayName(DefaultNameFormatter.getInstance()); 570 if (osm.isNewOrUndeleted() || osm.isModified()) { 571 name = "<i><b>"+ name + "*</b></i>"; 572 } 573 text.append(name); 574 575 boolean idShown = SHOW_ID.get(); 576 // fix #7557 - do not show ID twice 577 578 if (!osm.isNew() && !idShown) { 579 text.append(" [id=").append(osm.getId()).append(']'); 580 } 581 582 if (osm.getUser() != null) { 583 text.append(" [").append(tr("User:")).append(' ').append(osm.getUser().getName()).append(']'); 584 } 585 586 for (String key : osm.keySet()) { 587 text.append("<br>").append(key).append('=').append(osm.get(key)); 588 } 589 590 final JLabel l = new JLabel( 591 "<html>" + text.toString() + "</html>", 592 ImageProvider.get(osm.getDisplayType()), 593 JLabel.HORIZONTAL 594 ) { 595 // This is necessary so the label updates its colors when the 596 // selection is changed from the outside 597 @Override 598 public void validate() { 599 super.validate(); 600 popupSetLabelColors(this, osm); 601 } 602 }; 603 l.setOpaque(true); 604 popupSetLabelColors(l, osm); 605 l.setFont(l.getFont().deriveFont(Font.PLAIN)); 606 l.setVerticalTextPosition(JLabel.TOP); 607 l.setHorizontalAlignment(JLabel.LEFT); 608 l.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); 609 l.addMouseListener(new MouseAdapter() { 610 @Override 611 public void mouseEntered(MouseEvent e) { 612 l.setBackground(SystemColor.info); 613 l.setForeground(SystemColor.infoText); 614 } 615 616 @Override 617 public void mouseExited(MouseEvent e) { 618 popupSetLabelColors(l, osm); 619 } 620 621 @Override 622 public void mouseClicked(MouseEvent e) { 623 DataSet ds = Main.getLayerManager().getEditDataSet(); 624 // Let the user toggle the selection 625 ds.toggleSelected(osm); 626 l.validate(); 627 } 628 }); 629 // Sometimes the mouseEntered event is not catched, thus the label 630 // will not be highlighted, making it confusing. The MotionListener can correct this defect. 631 l.addMouseMotionListener(new MouseMotionListener() { 632 @Override 633 public void mouseMoved(MouseEvent e) { 634 l.setBackground(SystemColor.info); 635 l.setForeground(SystemColor.infoText); 636 } 637 638 @Override 639 public void mouseDragged(MouseEvent e) { 640 l.setBackground(SystemColor.info); 641 l.setForeground(SystemColor.infoText); 642 } 643 }); 644 return l; 645 } 646 647 /** 648 * Called whenever the mouse position or modifiers changed. 649 * @param mousePos The new mouse position. <code>null</code> if it did not change. 650 * @param modifiers The new modifiers. 651 */ 652 public synchronized void updateMousePosition(Point mousePos, int modifiers) { 653 if (mousePos != null) { 654 lastMousePos = mousePos; 655 } 656 MouseState ms = new MouseState(lastMousePos, modifiers); 657 // remove mouse states that are in the queue. Our mouse state is newer. 658 incomingMouseState.clear(); 659 if (!incomingMouseState.offer(ms)) { 660 Main.warn("Unable to handle new MouseState: " + ms); 661 } 662 } 663 } 664 665 /** 666 * Everything, the collector is interested of. Access must be synchronized. 667 * @author imi 668 */ 669 private static class MouseState { 670 private final Point mousePos; 671 private final int modifiers; 672 673 MouseState(Point mousePos, int modifiers) { 674 this.mousePos = mousePos; 675 this.modifiers = modifiers; 676 } 677 } 678 679 private final transient AWTEventListener awtListener; 680 681 private final transient MouseMotionListener mouseMotionListener = new MouseMotionListener() { 682 @Override 683 public void mouseMoved(MouseEvent e) { 684 synchronized (collector) { 685 collector.updateMousePosition(e.getPoint(), e.getModifiersEx()); 686 } 687 } 688 689 @Override 690 public void mouseDragged(MouseEvent e) { 691 mouseMoved(e); 692 } 693 }; 694 695 private final transient KeyAdapter keyAdapter = new KeyAdapter() { 696 @Override public void keyPressed(KeyEvent e) { 697 synchronized (collector) { 698 collector.updateMousePosition(null, e.getModifiersEx()); 699 } 700 } 701 702 @Override public void keyReleased(KeyEvent e) { 703 keyPressed(e); 704 } 705 }; 706 707 private void registerListeners() { 708 // Listen to keyboard/mouse events for pressing/releasing alt key and inform the collector. 709 try { 710 Toolkit.getDefaultToolkit().addAWTEventListener(awtListener, 711 AWTEvent.KEY_EVENT_MASK | AWTEvent.MOUSE_EVENT_MASK | AWTEvent.MOUSE_MOTION_EVENT_MASK); 712 } catch (SecurityException ex) { 713 Main.trace(ex); 714 mv.addMouseMotionListener(mouseMotionListener); 715 mv.addKeyListener(keyAdapter); 716 } 717 } 718 719 private void unregisterListeners() { 720 try { 721 Toolkit.getDefaultToolkit().removeAWTEventListener(awtListener); 722 } catch (SecurityException e) { 723 // Don't care, awtListener probably wasn't registered anyway 724 Main.trace(e); 725 } 726 mv.removeMouseMotionListener(mouseMotionListener); 727 mv.removeKeyListener(keyAdapter); 728 } 729 730 private class MapStatusPopupMenu extends JPopupMenu { 731 732 private final JMenuItem jumpButton = add(Main.main.menu.jumpToAct); 733 734 /** Icons for selecting {@link SystemOfMeasurement} */ 735 private final Collection<JCheckBoxMenuItem> somItems = new ArrayList<>(); 736 /** Icons for selecting {@link CoordinateFormat} */ 737 private final Collection<JCheckBoxMenuItem> coordinateFormatItems = new ArrayList<>(); 738 739 private final JSeparator separator = new JSeparator(); 740 741 private final JMenuItem doNotHide = new JCheckBoxMenuItem(new AbstractAction(tr("Do not hide status bar")) { 742 @Override 743 public void actionPerformed(ActionEvent e) { 744 boolean sel = ((JCheckBoxMenuItem) e.getSource()).getState(); 745 Main.pref.put("statusbar.always-visible", sel); 746 } 747 }); 748 749 MapStatusPopupMenu() { 750 for (final String key : new TreeSet<>(SystemOfMeasurement.ALL_SYSTEMS.keySet())) { 751 JCheckBoxMenuItem item = new JCheckBoxMenuItem(new AbstractAction(key) { 752 @Override 753 public void actionPerformed(ActionEvent e) { 754 updateSystemOfMeasurement(key); 755 } 756 }); 757 somItems.add(item); 758 add(item); 759 } 760 for (final CoordinateFormat format : CoordinateFormat.values()) { 761 JCheckBoxMenuItem item = new JCheckBoxMenuItem(new AbstractAction(format.getDisplayName()) { 762 @Override 763 public void actionPerformed(ActionEvent e) { 764 CoordinateFormat.setCoordinateFormat(format); 765 } 766 }); 767 coordinateFormatItems.add(item); 768 add(item); 769 } 770 771 add(separator); 772 add(doNotHide); 773 774 addPopupMenuListener(new PopupMenuListener() { 775 @Override 776 public void popupMenuWillBecomeVisible(PopupMenuEvent e) { 777 Component invoker = ((JPopupMenu) e.getSource()).getInvoker(); 778 jumpButton.setVisible(latText.equals(invoker) || lonText.equals(invoker)); 779 String currentSOM = ProjectionPreference.PROP_SYSTEM_OF_MEASUREMENT.get(); 780 for (JMenuItem item : somItems) { 781 item.setSelected(item.getText().equals(currentSOM)); 782 item.setVisible(distText.equals(invoker)); 783 } 784 final String currentCorrdinateFormat = CoordinateFormat.getDefaultFormat().getDisplayName(); 785 for (JMenuItem item : coordinateFormatItems) { 786 item.setSelected(currentCorrdinateFormat.equals(item.getText())); 787 item.setVisible(latText.equals(invoker) || lonText.equals(invoker)); 788 } 789 separator.setVisible(distText.equals(invoker) || latText.equals(invoker) || lonText.equals(invoker)); 790 doNotHide.setSelected(Main.pref.getBoolean("statusbar.always-visible", true)); 791 } 792 793 @Override 794 public void popupMenuWillBecomeInvisible(PopupMenuEvent e) { 795 // Do nothing 796 } 797 798 @Override 799 public void popupMenuCanceled(PopupMenuEvent e) { 800 // Do nothing 801 } 802 }); 803 } 804 } 805 806 /** 807 * Construct a new MapStatus and attach it to the map view. 808 * @param mapFrame The MapFrame the status line is part of. 809 */ 810 public MapStatus(final MapFrame mapFrame) { 811 this.mv = mapFrame.mapView; 812 this.collector = new Collector(mapFrame); 813 this.awtListener = event -> { 814 if (event instanceof InputEvent && 815 ((InputEvent) event).getComponent() == mv) { 816 synchronized (collector) { 817 int modifiers = ((InputEvent) event).getModifiersEx(); 818 Point mousePos = null; 819 if (event instanceof MouseEvent) { 820 mousePos = ((MouseEvent) event).getPoint(); 821 } 822 collector.updateMousePosition(mousePos, modifiers); 823 } 824 } 825 }; 826 827 // Context menu of status bar 828 setComponentPopupMenu(new MapStatusPopupMenu()); 829 830 // also show Jump To dialog on mouse click (except context menu) 831 MouseListener jumpToOnLeftClick = new MouseAdapter() { 832 @Override 833 public void mouseClicked(MouseEvent e) { 834 if (e.getButton() != MouseEvent.BUTTON3) { 835 Main.main.menu.jumpToAct.showJumpToDialog(); 836 } 837 } 838 }; 839 840 // Listen for mouse movements and set the position text field 841 mv.addMouseMotionListener(new MouseMotionListener() { 842 @Override 843 public void mouseDragged(MouseEvent e) { 844 mouseMoved(e); 845 } 846 847 @Override 848 public void mouseMoved(MouseEvent e) { 849 if (mv.getCenter() == null) 850 return; 851 // Do not update the view if ctrl is pressed. 852 if ((e.getModifiersEx() & MouseEvent.CTRL_DOWN_MASK) == 0) { 853 CoordinateFormat mCord = CoordinateFormat.getDefaultFormat(); 854 LatLon p = mv.getLatLon(e.getX(), e.getY()); 855 latText.setText(p.latToString(mCord)); 856 lonText.setText(p.lonToString(mCord)); 857 if (Objects.equals(previousCoordinateFormat, mCord)) { 858 // do nothing 859 } else if (CoordinateFormat.EAST_NORTH.equals(mCord)) { 860 latText.setIcon("northing"); 861 lonText.setIcon("easting"); 862 latText.setToolTipText(tr("The northing at the mouse pointer.")); 863 lonText.setToolTipText(tr("The easting at the mouse pointer.")); 864 previousCoordinateFormat = mCord; 865 } else { 866 latText.setIcon("lat"); 867 lonText.setIcon("lon"); 868 latText.setToolTipText(tr("The geographic latitude at the mouse pointer.")); 869 lonText.setToolTipText(tr("The geographic longitude at the mouse pointer.")); 870 previousCoordinateFormat = mCord; 871 } 872 } 873 } 874 }); 875 876 setLayout(new GridBagLayout()); 877 setBorder(BorderFactory.createEmptyBorder(1, 2, 1, 2)); 878 879 latText.setInheritsPopupMenu(true); 880 lonText.setInheritsPopupMenu(true); 881 headingText.setInheritsPopupMenu(true); 882 distText.setInheritsPopupMenu(true); 883 nameText.setInheritsPopupMenu(true); 884 885 add(latText, GBC.std()); 886 add(lonText, GBC.std().insets(3, 0, 0, 0)); 887 add(headingText, GBC.std().insets(3, 0, 0, 0)); 888 add(angleText, GBC.std().insets(3, 0, 0, 0)); 889 add(distText, GBC.std().insets(3, 0, 0, 0)); 890 891 if (Main.pref.getBoolean("statusbar.change-system-of-measurement-on-click", true)) { 892 distText.addMouseListener(new MouseAdapter() { 893 private final List<String> soms = new ArrayList<>(new TreeSet<>(SystemOfMeasurement.ALL_SYSTEMS.keySet())); 894 895 @Override 896 public void mouseClicked(MouseEvent e) { 897 if (!e.isPopupTrigger() && e.getButton() == MouseEvent.BUTTON1) { 898 String som = ProjectionPreference.PROP_SYSTEM_OF_MEASUREMENT.get(); 899 String newsom = soms.get((soms.indexOf(som)+1) % soms.size()); 900 updateSystemOfMeasurement(newsom); 901 } 902 } 903 }); 904 } 905 906 SystemOfMeasurement.addSoMChangeListener(this); 907 908 latText.addMouseListener(jumpToOnLeftClick); 909 lonText.addMouseListener(jumpToOnLeftClick); 910 911 helpText.setEditable(false); 912 add(nameText, GBC.std().insets(3, 0, 0, 0)); 913 add(helpText, GBC.std().insets(3, 0, 0, 0).fill(GBC.HORIZONTAL)); 914 915 progressBar.setMaximum(PleaseWaitProgressMonitor.PROGRESS_BAR_MAX); 916 progressBar.setVisible(false); 917 GBC gbc = GBC.eol(); 918 gbc.ipadx = 100; 919 add(progressBar, gbc); 920 progressBar.addMouseListener(new MouseAdapter() { 921 @Override 922 public void mouseClicked(MouseEvent e) { 923 PleaseWaitProgressMonitor monitor = Main.currentProgressMonitor; 924 if (monitor != null) { 925 monitor.showForegroundDialog(); 926 } 927 } 928 }); 929 930 Main.pref.addPreferenceChangeListener(this); 931 932 mvComponentAdapter = new ComponentAdapter() { 933 @Override 934 public void componentResized(ComponentEvent e) { 935 nameText.setCharCount(getNameLabelCharacterCount(Main.parent)); 936 revalidate(); 937 } 938 }; 939 mv.addComponentListener(mvComponentAdapter); 940 941 // The background thread 942 thread = new Thread(collector, "Map Status Collector"); 943 thread.setDaemon(true); 944 thread.start(); 945 } 946 947 @Override 948 public void systemOfMeasurementChanged(String oldSoM, String newSoM) { 949 setDist(distValue); 950 } 951 952 /** 953 * Updates the system of measurement and displays a notification. 954 * @param newsom The new system of measurement to set 955 * @since 6960 956 */ 957 public void updateSystemOfMeasurement(String newsom) { 958 SystemOfMeasurement.setSystemOfMeasurement(newsom); 959 if (Main.pref.getBoolean("statusbar.notify.change-system-of-measurement", true)) { 960 new Notification(tr("System of measurement changed to {0}", newsom)) 961 .setDuration(Notification.TIME_SHORT) 962 .show(); 963 } 964 } 965 966 public JPanel getAnglePanel() { 967 return angleText; 968 } 969 970 @Override 971 public String helpTopic() { 972 return ht("/StatusBar"); 973 } 974 975 @Override 976 public synchronized void addMouseListener(MouseListener ml) { 977 lonText.addMouseListener(ml); 978 latText.addMouseListener(ml); 979 } 980 981 public void setHelpText(String t) { 982 setHelpText(null, t); 983 } 984 985 public void setHelpText(Object id, final String text) { 986 987 StatusTextHistory entry = new StatusTextHistory(id, text); 988 989 statusText.remove(entry); 990 statusText.add(entry); 991 992 GuiHelper.runInEDT(() -> { 993 helpText.setText(text); 994 helpText.setToolTipText(text); 995 }); 996 } 997 998 public void resetHelpText(Object id) { 999 if (statusText.isEmpty()) 1000 return; 1001 1002 StatusTextHistory entry = new StatusTextHistory(id, null); 1003 if (statusText.get(statusText.size() - 1).equals(entry)) { 1004 if (statusText.size() == 1) { 1005 setHelpText(""); 1006 } else { 1007 StatusTextHistory history = statusText.get(statusText.size() - 2); 1008 setHelpText(history.id, history.text); 1009 } 1010 } 1011 statusText.remove(entry); 1012 } 1013 1014 public void setAngle(double a) { 1015 angleText.setText(a < 0 ? "--" : DECIMAL_FORMAT.format(a) + " \u00B0"); 1016 } 1017 1018 public void setHeading(double h) { 1019 headingText.setText(h < 0 ? "--" : DECIMAL_FORMAT.format(h) + " \u00B0"); 1020 } 1021 1022 /** 1023 * Sets the distance text to the given value 1024 * @param dist The distance value to display, in meters 1025 */ 1026 public void setDist(double dist) { 1027 distValue = dist; 1028 distText.setText(dist < 0 ? "--" : NavigatableComponent.getDistText(dist, DECIMAL_FORMAT, DISTANCE_THRESHOLD.get())); 1029 } 1030 1031 /** 1032 * Sets the distance text to the total sum of given ways length 1033 * @param ways The ways to consider for the total distance 1034 * @since 5991 1035 */ 1036 public void setDist(Collection<Way> ways) { 1037 double dist = -1; 1038 // Compute total length of selected way(s) until an arbitrary limit set to 250 ways 1039 // in order to prevent performance issue if a large number of ways are selected (old behaviour kept in that case, see #8403) 1040 int maxWays = Math.max(1, Main.pref.getInteger("selection.max-ways-for-statusline", 250)); 1041 if (!ways.isEmpty() && ways.size() <= maxWays) { 1042 dist = 0.0; 1043 for (Way w : ways) { 1044 dist += w.getLength(); 1045 } 1046 } 1047 setDist(dist); 1048 } 1049 1050 /** 1051 * Activates the angle panel. 1052 * @param activeFlag {@code true} to activate it, {@code false} to deactivate it 1053 */ 1054 public void activateAnglePanel(boolean activeFlag) { 1055 angleEnabled = activeFlag; 1056 refreshAnglePanel(); 1057 } 1058 1059 private void refreshAnglePanel() { 1060 angleText.setBackground(angleEnabled ? PROP_ACTIVE_BACKGROUND_COLOR.get() : PROP_BACKGROUND_COLOR.get()); 1061 angleText.setForeground(angleEnabled ? PROP_ACTIVE_FOREGROUND_COLOR.get() : PROP_FOREGROUND_COLOR.get()); 1062 } 1063 1064 @Override 1065 public void destroy() { 1066 SystemOfMeasurement.removeSoMChangeListener(this); 1067 Main.pref.removePreferenceChangeListener(this); 1068 mv.removeComponentListener(mvComponentAdapter); 1069 1070 // MapFrame gets destroyed when the last layer is removed, but the status line background 1071 // thread that collects the information doesn't get destroyed automatically. 1072 if (thread != null) { 1073 try { 1074 thread.interrupt(); 1075 } catch (RuntimeException e) { 1076 Main.error(e); 1077 } 1078 } 1079 } 1080 1081 @Override 1082 public void preferenceChanged(PreferenceChangeEvent e) { 1083 String key = e.getKey(); 1084 if (key.startsWith("color.")) { 1085 key = key.substring("color.".length()); 1086 if (PROP_BACKGROUND_COLOR.getKey().equals(key) || PROP_FOREGROUND_COLOR.getKey().equals(key)) { 1087 for (ImageLabel il : new ImageLabel[]{latText, lonText, headingText, distText, nameText}) { 1088 il.setBackground(PROP_BACKGROUND_COLOR.get()); 1089 il.setForeground(PROP_FOREGROUND_COLOR.get()); 1090 } 1091 refreshAnglePanel(); 1092 } else if (PROP_ACTIVE_BACKGROUND_COLOR.getKey().equals(key) || PROP_ACTIVE_FOREGROUND_COLOR.getKey().equals(key)) { 1093 refreshAnglePanel(); 1094 } 1095 } 1096 } 1097 1098 /** 1099 * Loads all colors from preferences. 1100 * @since 6789 1101 */ 1102 public static void getColors() { 1103 PROP_BACKGROUND_COLOR.get(); 1104 PROP_FOREGROUND_COLOR.get(); 1105 PROP_ACTIVE_BACKGROUND_COLOR.get(); 1106 PROP_ACTIVE_FOREGROUND_COLOR.get(); 1107 } 1108 1109 private static int getNameLabelCharacterCount(Component parent) { 1110 int w = parent != null ? parent.getWidth() : 800; 1111 return Math.min(80, 20 + Math.max(0, w-1280) * 60 / (1920-1280)); 1112 } 1113}