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