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