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.SystemOfMeasurement; 063import org.openstreetmap.josm.data.SystemOfMeasurement.SoMChangeListener; 064import org.openstreetmap.josm.data.coor.LatLon; 065import org.openstreetmap.josm.data.coor.conversion.CoordinateFormatManager; 066import org.openstreetmap.josm.data.coor.conversion.DMSCoordinateFormat; 067import org.openstreetmap.josm.data.coor.conversion.ICoordinateFormat; 068import org.openstreetmap.josm.data.coor.conversion.ProjectedCoordinateFormat; 069import org.openstreetmap.josm.data.osm.DataSelectionListener; 070import org.openstreetmap.josm.data.osm.DataSet; 071import org.openstreetmap.josm.data.osm.DefaultNameFormatter; 072import org.openstreetmap.josm.data.osm.IPrimitive; 073import org.openstreetmap.josm.data.osm.Node; 074import org.openstreetmap.josm.data.osm.OsmPrimitive; 075import org.openstreetmap.josm.data.osm.Way; 076import org.openstreetmap.josm.data.osm.event.AbstractDatasetChangedEvent; 077import org.openstreetmap.josm.data.osm.event.DataChangedEvent; 078import org.openstreetmap.josm.data.osm.event.DataSetListener; 079import org.openstreetmap.josm.data.osm.event.DatasetEventManager; 080import org.openstreetmap.josm.data.osm.event.DatasetEventManager.FireMode; 081import org.openstreetmap.josm.data.osm.event.NodeMovedEvent; 082import org.openstreetmap.josm.data.osm.event.PrimitivesAddedEvent; 083import org.openstreetmap.josm.data.osm.event.PrimitivesRemovedEvent; 084import org.openstreetmap.josm.data.osm.event.RelationMembersChangedEvent; 085import org.openstreetmap.josm.data.osm.event.SelectionEventManager; 086import org.openstreetmap.josm.data.osm.event.TagsChangedEvent; 087import org.openstreetmap.josm.data.osm.event.WayNodesChangedEvent; 088import org.openstreetmap.josm.data.preferences.AbstractProperty; 089import org.openstreetmap.josm.data.preferences.BooleanProperty; 090import org.openstreetmap.josm.data.preferences.DoubleProperty; 091import org.openstreetmap.josm.data.preferences.NamedColorProperty; 092import org.openstreetmap.josm.gui.NavigatableComponent.ZoomChangeListener; 093import org.openstreetmap.josm.gui.help.Helpful; 094import org.openstreetmap.josm.gui.progress.swing.PleaseWaitProgressMonitor; 095import org.openstreetmap.josm.gui.progress.swing.PleaseWaitProgressMonitor.ProgressMonitorDialog; 096import org.openstreetmap.josm.gui.util.GuiHelper; 097import org.openstreetmap.josm.gui.widgets.ImageLabel; 098import org.openstreetmap.josm.gui.widgets.JosmTextField; 099import org.openstreetmap.josm.spi.preferences.Config; 100import org.openstreetmap.josm.spi.preferences.PreferenceChangeEvent; 101import org.openstreetmap.josm.spi.preferences.PreferenceChangedListener; 102import org.openstreetmap.josm.tools.ColorHelper; 103import org.openstreetmap.josm.tools.Destroyable; 104import org.openstreetmap.josm.tools.GBC; 105import org.openstreetmap.josm.tools.ImageProvider; 106import org.openstreetmap.josm.tools.Logging; 107import org.openstreetmap.josm.tools.SubclassFilteredCollection; 108import org.openstreetmap.josm.tools.Utils; 109 110/** 111 * A component that manages some status information display about the map. 112 * It keeps a status line below the map up to date and displays some tooltip 113 * information if the user hold the mouse long enough at some point. 114 * 115 * All this is done in background to not disturb other processes. 116 * 117 * The background thread does not alter any data of the map (read only thread). 118 * Also it is rather fail safe. In case of some error in the data, it just does 119 * nothing instead of whining and complaining. 120 * 121 * @author imi 122 */ 123public final class MapStatus extends JPanel implements 124 Helpful, Destroyable, PreferenceChangedListener, SoMChangeListener, DataSelectionListener, DataSetListener, ZoomChangeListener { 125 126 private final DecimalFormat DECIMAL_FORMAT = new DecimalFormat(Config.getPref().get("statusbar.decimal-format", "0.0")); 127 private static final AbstractProperty<Double> DISTANCE_THRESHOLD = new DoubleProperty("statusbar.distance-threshold", 0.01).cached(); 128 129 private static final AbstractProperty<Boolean> SHOW_ID = new BooleanProperty("osm-primitives.showid", false); 130 131 /** 132 * Property for map status background color. 133 * @since 6789 134 */ 135 public static final NamedColorProperty PROP_BACKGROUND_COLOR = new NamedColorProperty( 136 marktr("Status bar background"), ColorHelper.html2color("#b8cfe5")); 137 138 /** 139 * Property for map status background color (active state). 140 * @since 6789 141 */ 142 public static final NamedColorProperty PROP_ACTIVE_BACKGROUND_COLOR = new NamedColorProperty( 143 marktr("Status bar background: active"), ColorHelper.html2color("#aaff5e")); 144 145 /** 146 * Property for map status foreground color. 147 * @since 6789 148 */ 149 public static final NamedColorProperty PROP_FOREGROUND_COLOR = new NamedColorProperty( 150 marktr("Status bar foreground"), Color.black); 151 152 /** 153 * Property for map status foreground color (active state). 154 * @since 6789 155 */ 156 public static final NamedColorProperty PROP_ACTIVE_FOREGROUND_COLOR = new NamedColorProperty( 157 marktr("Status bar foreground: active"), Color.black); 158 159 /** 160 * The MapView this status belongs to. 161 */ 162 private final MapView mv; 163 private final transient Collector collector; 164 165 static final class ShowMonitorDialogMouseAdapter extends MouseAdapter { 166 @Override 167 public void mouseClicked(MouseEvent e) { 168 PleaseWaitProgressMonitor monitor = PleaseWaitProgressMonitor.getCurrent(); 169 if (monitor != null) { 170 monitor.showForegroundDialog(); 171 } 172 } 173 } 174 175 static final class JumpToOnLeftClickMouseAdapter extends MouseAdapter { 176 @Override 177 public void mouseClicked(MouseEvent e) { 178 if (e.getButton() != MouseEvent.BUTTON3) { 179 MainApplication.getMenu().jumpToAct.showJumpToDialog(); 180 } 181 } 182 } 183 184 /** 185 * The progress monitor that is used to display the progress if the user selects to run in background 186 */ 187 public class BackgroundProgressMonitor implements ProgressMonitorDialog { 188 189 private String title; 190 private String customText; 191 192 private void updateText() { 193 if (customText != null && !customText.isEmpty()) { 194 progressBar.setToolTipText(tr("{0} ({1})", title, customText)); 195 } else { 196 progressBar.setToolTipText(title); 197 } 198 } 199 200 @Override 201 public void setVisible(boolean visible) { 202 progressBar.setVisible(visible); 203 } 204 205 @Override 206 public void updateProgress(int progress) { 207 progressBar.setValue(progress); 208 progressBar.repaint(); 209 MapStatus.this.doLayout(); 210 } 211 212 @Override 213 public void setCustomText(String text) { 214 this.customText = text; 215 updateText(); 216 } 217 218 @Override 219 public void setCurrentAction(String text) { 220 this.title = text; 221 updateText(); 222 } 223 224 @Override 225 public void setIndeterminate(boolean newValue) { 226 UIManager.put("ProgressBar.cycleTime", UIManager.getInt("ProgressBar.repaintInterval") * 100); 227 progressBar.setIndeterminate(newValue); 228 } 229 230 @Override 231 public void appendLogMessage(String message) { 232 if (message != null && !message.isEmpty()) { 233 Logging.info("appendLogMessage not implemented for background tasks. Message was: " + message); 234 } 235 } 236 237 } 238 239 /** The {@link ICoordinateFormat} set in the previous update */ 240 private transient ICoordinateFormat previousCoordinateFormat; 241 private final ImageLabel latText = new ImageLabel("lat", 242 null, DMSCoordinateFormat.INSTANCE.latToString(LatLon.SOUTH_POLE).length(), PROP_BACKGROUND_COLOR.get()); 243 private final ImageLabel lonText = new ImageLabel("lon", 244 null, DMSCoordinateFormat.INSTANCE.lonToString(new LatLon(0, 180)).length(), PROP_BACKGROUND_COLOR.get()); 245 private final ImageLabel headingText = new ImageLabel("heading", 246 tr("The (compass) heading of the line segment being drawn."), 247 DECIMAL_FORMAT.format(360).length() + 1, PROP_BACKGROUND_COLOR.get()); 248 private final ImageLabel angleText = new ImageLabel("angle", 249 tr("The angle between the previous and the current way segment."), 250 DECIMAL_FORMAT.format(360).length() + 1, PROP_BACKGROUND_COLOR.get()); 251 private final ImageLabel distText = new ImageLabel("dist", 252 tr("The length of the new way segment being drawn."), 10, PROP_BACKGROUND_COLOR.get()); 253 private final ImageLabel nameText = new ImageLabel("name", 254 tr("The name of the object at the mouse pointer."), getNameLabelCharacterCount(Main.parent), PROP_BACKGROUND_COLOR.get()); 255 private final JosmTextField helpText = new JosmTextField(); 256 private final JProgressBar progressBar = new JProgressBar(); 257 private final transient ComponentAdapter mvComponentAdapter; 258 /** 259 * The progress monitor for displaying a background progress 260 */ 261 public final transient BackgroundProgressMonitor progressMonitor = new BackgroundProgressMonitor(); 262 263 // Distance value displayed in distText, stored if refresh needed after a change of system of measurement 264 private double distValue; 265 266 // Determines if angle panel is enabled or not 267 private boolean angleEnabled; 268 269 /** 270 * This is the thread that runs in the background and collects the information displayed. 271 * It gets destroyed by destroy() when the MapFrame itself is destroyed. 272 */ 273 private final transient Thread thread; 274 275 private final transient List<StatusTextHistory> statusText = new ArrayList<>(); 276 277 protected static final class StatusTextHistory { 278 private final Object id; 279 private final String text; 280 281 StatusTextHistory(Object id, String text) { 282 this.id = id; 283 this.text = text; 284 } 285 286 @Override 287 public boolean equals(Object obj) { 288 return obj instanceof StatusTextHistory && ((StatusTextHistory) obj).id == id; 289 } 290 291 @Override 292 public int hashCode() { 293 return System.identityHashCode(id); 294 } 295 } 296 297 /** 298 * The collector class that waits for notification and then update the display objects. 299 * 300 * @author imi 301 */ 302 private final class Collector implements Runnable { 303 private final class CollectorWorker implements Runnable { 304 private final MouseState ms; 305 306 private CollectorWorker(MouseState ms) { 307 this.ms = ms; 308 } 309 310 @Override 311 public void run() { 312 // Freeze display when holding down CTRL 313 if ((ms.modifiers & MouseEvent.CTRL_DOWN_MASK) != 0) { 314 // update the information popup's labels though, because the selection might have changed from the outside 315 popupUpdateLabels(); 316 return; 317 } 318 319 // This try/catch is a hack to stop the flooding bug reports about this. 320 // The exception needed to handle with in the first place, means that this 321 // access to the data need to be restarted, if the main thread modifies the data. 322 DataSet ds = null; 323 // The popup != null check is required because a left-click produces several events as well, 324 // which would make this variable true. Of course we only want the popup to show 325 // if the middle mouse button has been pressed in the first place 326 boolean mouseNotMoved = oldMousePos != null && oldMousePos.equals(ms.mousePos); 327 boolean isAtOldPosition = mouseNotMoved && popup != null; 328 boolean middleMouseDown = (ms.modifiers & MouseEvent.BUTTON2_DOWN_MASK) != 0; 329 330 ds = mv.getLayerManager().getActiveDataSet(); 331 if (ds != null) { 332 // This is not perfect, if current dataset was changed during execution, the lock would be useless 333 if (isAtOldPosition && middleMouseDown) { 334 // Write lock is necessary when selecting in popupCycleSelection 335 // locks can not be upgraded -> if do read lock here and write lock later 336 // (in OsmPrimitive.updateFlags) then always occurs deadlock (#5814) 337 ds.beginUpdate(); 338 } else { 339 ds.getReadLock().lock(); 340 } 341 } 342 try { 343 // Set the text label in the bottom status bar 344 // "if mouse moved only" was added to stop heap growing 345 if (!mouseNotMoved) { 346 statusBarElementUpdate(ms); 347 } 348 349 // Popup Information 350 // display them if the middle mouse button is pressed and keep them until the mouse is moved 351 if (middleMouseDown || isAtOldPosition) { 352 Collection<OsmPrimitive> osms = mv.getAllNearest(ms.mousePos, OsmPrimitive::isSelectable); 353 354 final JPanel c = new JPanel(new GridBagLayout()); 355 final JLabel lbl = new JLabel( 356 "<html>"+tr("Middle click again to cycle through.<br>"+ 357 "Hold CTRL to select directly from this list with the mouse.<hr>")+"</html>", 358 null, 359 JLabel.HORIZONTAL 360 ); 361 lbl.setHorizontalAlignment(JLabel.LEFT); 362 c.add(lbl, GBC.eol().insets(2, 0, 2, 0)); 363 364 // Only cycle if the mouse has not been moved and the middle mouse button has been pressed at least 365 // twice (the reason for this is the popup != null check for isAtOldPosition, see above. 366 // This is a nice side effect though, because it does not change selection of the first middle click) 367 if (isAtOldPosition && middleMouseDown) { 368 // Hand down mouse modifiers so the SHIFT mod can be handled correctly (see function) 369 popupCycleSelection(osms, ms.modifiers); 370 } 371 372 // These labels may need to be updated from the outside so collect them 373 List<JLabel> lbls = new ArrayList<>(osms.size()); 374 for (final OsmPrimitive osm : osms) { 375 JLabel l = popupBuildPrimitiveLabels(osm); 376 lbls.add(l); 377 c.add(l, GBC.eol().fill(GBC.HORIZONTAL).insets(2, 0, 2, 2)); 378 } 379 380 popupShowPopup(popupCreatePopup(c, ms), lbls); 381 } else { 382 popupHidePopup(); 383 } 384 385 oldMousePos = ms.mousePos; 386 } catch (ConcurrentModificationException ex) { 387 Logging.warn(ex); 388 } finally { 389 if (ds != null) { 390 if (isAtOldPosition && middleMouseDown) { 391 ds.endUpdate(); 392 } else { 393 ds.getReadLock().unlock(); 394 } 395 } 396 } 397 } 398 } 399 400 /** 401 * the mouse position of the previous iteration. This is used to show 402 * the popup until the cursor is moved. 403 */ 404 private Point oldMousePos; 405 /** 406 * Contains the labels that are currently shown in the information 407 * popup 408 */ 409 private List<JLabel> popupLabels; 410 /** 411 * The popup displayed to show additional information 412 */ 413 private Popup popup; 414 415 private final MapFrame parent; 416 417 private final BlockingQueue<MouseState> incomingMouseState = new LinkedBlockingQueue<>(); 418 419 private Point lastMousePos; 420 421 Collector(MapFrame parent) { 422 this.parent = parent; 423 } 424 425 /** 426 * Execution function for the Collector. 427 */ 428 @Override 429 public void run() { 430 registerListeners(); 431 try { 432 for (;;) { 433 try { 434 final MouseState ms = incomingMouseState.take(); 435 if (parent != MainApplication.getMap()) 436 return; // exit, if new parent. 437 438 // Do nothing, if required data is missing 439 if (ms.mousePos == null || mv.getCenter() == null) { 440 continue; 441 } 442 443 EventQueue.invokeAndWait(new CollectorWorker(ms)); 444 } catch (InvocationTargetException e) { 445 Logging.warn(e); 446 } 447 } 448 } catch (InterruptedException e) { 449 // Occurs frequently during JOSM shutdown, log set to trace only 450 Logging.trace("InterruptedException in "+MapStatus.class.getSimpleName()); 451 Thread.currentThread().interrupt(); 452 } finally { 453 unregisterListeners(); 454 } 455 } 456 457 /** 458 * Creates a popup for the given content next to the cursor. Tries to 459 * keep the popup on screen and shows a vertical scrollbar, if the 460 * screen is too small. 461 * @param content popup content 462 * @param ms mouse state 463 * @return popup 464 */ 465 private Popup popupCreatePopup(Component content, MouseState ms) { 466 Point p = mv.getLocationOnScreen(); 467 Dimension scrn = GuiHelper.getScreenSize(); 468 469 // Create a JScrollPane around the content, in case there's not enough space 470 JScrollPane sp = GuiHelper.embedInVerticalScrollPane(content); 471 sp.setBorder(BorderFactory.createRaisedBevelBorder()); 472 // Implement max-size content-independent 473 Dimension prefsize = sp.getPreferredSize(); 474 int w = Math.min(prefsize.width, Math.min(800, (scrn.width/2) - 16)); 475 int h = Math.min(prefsize.height, scrn.height - 10); 476 sp.setPreferredSize(new Dimension(w, h)); 477 478 int xPos = p.x + ms.mousePos.x + 16; 479 // Display the popup to the left of the cursor if it would be cut 480 // off on its right, but only if more space is available 481 if (xPos + w > scrn.width && xPos > scrn.width/2) { 482 xPos = p.x + ms.mousePos.x - 4 - w; 483 } 484 int yPos = p.y + ms.mousePos.y + 16; 485 // Move the popup up if it would be cut off at its bottom but do not 486 // move it off screen on the top 487 if (yPos + h > scrn.height - 5) { 488 yPos = Math.max(5, scrn.height - h - 5); 489 } 490 491 PopupFactory pf = PopupFactory.getSharedInstance(); 492 return pf.getPopup(mv, sp, xPos, yPos); 493 } 494 495 /** 496 * Calls this to update the element that is shown in the statusbar 497 * @param ms mouse state 498 */ 499 private void statusBarElementUpdate(MouseState ms) { 500 final OsmPrimitive osmNearest = mv.getNearestNodeOrWay(ms.mousePos, OsmPrimitive::isUsable, false); 501 if (osmNearest != null) { 502 nameText.setText(osmNearest.getDisplayName(DefaultNameFormatter.getInstance())); 503 } else { 504 nameText.setText(tr("(no object)")); 505 } 506 } 507 508 /** 509 * Call this with a set of primitives to cycle through them. Method 510 * will automatically select the next item and update the map 511 * @param osms primitives to cycle through 512 * @param mods modifiers (i.e. control keys) 513 */ 514 private void popupCycleSelection(Collection<OsmPrimitive> osms, int mods) { 515 DataSet ds = MainApplication.getLayerManager().getActiveDataSet(); 516 // Find some items that are required for cycling through 517 OsmPrimitive firstItem = null; 518 OsmPrimitive firstSelected = null; 519 OsmPrimitive nextSelected = null; 520 for (final OsmPrimitive osm : osms) { 521 if (firstItem == null) { 522 firstItem = osm; 523 } 524 if (firstSelected != null && nextSelected == null) { 525 nextSelected = osm; 526 } 527 if (firstSelected == null && ds.isSelected(osm)) { 528 firstSelected = osm; 529 } 530 } 531 532 // Clear previous selection if SHIFT (add to selection) is not 533 // pressed. Cannot use "setSelected()" because it will cause a 534 // fireSelectionChanged event which is unnecessary at this point. 535 if ((mods & MouseEvent.SHIFT_DOWN_MASK) == 0) { 536 ds.clearSelection(); 537 } 538 539 // This will cycle through the available items. 540 if (firstSelected != null) { 541 ds.clearSelection(firstSelected); 542 if (nextSelected != null) { 543 ds.addSelected(nextSelected); 544 } 545 } else if (firstItem != null) { 546 ds.addSelected(firstItem); 547 } 548 } 549 550 /** 551 * Tries to hide the given popup 552 */ 553 private void popupHidePopup() { 554 popupLabels = null; 555 if (popup == null) 556 return; 557 final Popup staticPopup = popup; 558 popup = null; 559 EventQueue.invokeLater(staticPopup::hide); 560 } 561 562 /** 563 * Tries to show the given popup, can be hidden using {@link #popupHidePopup} 564 * If an old popup exists, it will be automatically hidden 565 * @param newPopup popup to show 566 * @param lbls lables to show (see {@link #popupLabels}) 567 */ 568 private void popupShowPopup(Popup newPopup, List<JLabel> lbls) { 569 final Popup staticPopup = newPopup; 570 if (this.popup != null) { 571 // If an old popup exists, remove it when the new popup has been drawn to keep flickering to a minimum 572 final Popup staticOldPopup = this.popup; 573 EventQueue.invokeLater(() -> { 574 staticPopup.show(); 575 staticOldPopup.hide(); 576 }); 577 } else { 578 // There is no old popup 579 EventQueue.invokeLater(staticPopup::show); 580 } 581 this.popupLabels = lbls; 582 this.popup = newPopup; 583 } 584 585 /** 586 * This method should be called if the selection may have changed from 587 * outside of this class. This is the case when CTRL is pressed and the 588 * user clicks on the map instead of the popup. 589 */ 590 private void popupUpdateLabels() { 591 if (this.popup == null || this.popupLabels == null) 592 return; 593 for (JLabel l : this.popupLabels) { 594 l.validate(); 595 } 596 } 597 598 /** 599 * Sets the colors for the given label depending on the selected status of 600 * the given OsmPrimitive 601 * 602 * @param lbl The label to color 603 * @param osm The primitive to derive the colors from 604 */ 605 private void popupSetLabelColors(JLabel lbl, IPrimitive osm) { 606 if (osm.isSelected()) { 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().addSelectionListenerForEdt(this); 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(SelectionChangeEvent event) { 1207 refreshDistText(event.getSelection()); 1208 } 1209 1210 @Override 1211 public void zoomChanged() { 1212 if (!GraphicsEnvironment.isHeadless()) { 1213 try { 1214 PointerInfo pointerInfo = MouseInfo.getPointerInfo(); 1215 if (pointerInfo != null) { 1216 Point mp = pointerInfo.getLocation(); 1217 updateLatLonText(mp.x, mp.y); 1218 } 1219 } catch (SecurityException ex) { 1220 Logging.log(Logging.LEVEL_ERROR, "Unable to get mouse pointer info", ex); 1221 } 1222 } 1223 } 1224 1225 @Override 1226 public void wayNodesChanged(WayNodesChangedEvent event) { 1227 Collection<OsmPrimitive> sel = event.getDataset().getSelected(); 1228 if (sel.size() == 1 && sel.contains(event.getChangedWay())) { 1229 refreshDistText(sel); 1230 } 1231 } 1232 1233 @Override 1234 public void nodeMoved(NodeMovedEvent event) { 1235 Collection<OsmPrimitive> sel = event.getDataset().getSelected(); 1236 if (sel.size() == 2 && sel.contains(event.getNode())) { 1237 refreshDistText(sel); 1238 } 1239 } 1240 1241 @Override 1242 public void primitivesAdded(PrimitivesAddedEvent event) { 1243 // Do nothing 1244 } 1245 1246 @Override 1247 public void primitivesRemoved(PrimitivesRemovedEvent event) { 1248 // Do nothing 1249 } 1250 1251 @Override 1252 public void tagsChanged(TagsChangedEvent event) { 1253 // Do nothing 1254 } 1255 1256 @Override 1257 public void relationMembersChanged(RelationMembersChangedEvent event) { 1258 // Do nothing 1259 } 1260 1261 @Override 1262 public void otherDatasetChange(AbstractDatasetChangedEvent event) { 1263 // Do nothing 1264 } 1265 1266 @Override 1267 public void dataChanged(DataChangedEvent event) { 1268 // Do nothing 1269 } 1270}