001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.dialogs.validator; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.awt.event.KeyListener; 007import java.awt.event.MouseEvent; 008import java.util.ArrayList; 009import java.util.Collection; 010import java.util.Enumeration; 011import java.util.HashSet; 012import java.util.List; 013import java.util.Map; 014import java.util.Map.Entry; 015import java.util.Set; 016import java.util.function.Consumer; 017import java.util.function.Predicate; 018 019import javax.swing.JTree; 020import javax.swing.ToolTipManager; 021import javax.swing.tree.DefaultMutableTreeNode; 022import javax.swing.tree.DefaultTreeModel; 023import javax.swing.tree.TreeNode; 024import javax.swing.tree.TreePath; 025import javax.swing.tree.TreeSelectionModel; 026 027import org.openstreetmap.josm.data.osm.OsmPrimitive; 028import org.openstreetmap.josm.data.osm.event.AbstractDatasetChangedEvent; 029import org.openstreetmap.josm.data.osm.event.DataChangedEvent; 030import org.openstreetmap.josm.data.osm.event.DataSetListener; 031import org.openstreetmap.josm.data.osm.event.DatasetEventManager; 032import org.openstreetmap.josm.data.osm.event.NodeMovedEvent; 033import org.openstreetmap.josm.data.osm.event.PrimitivesAddedEvent; 034import org.openstreetmap.josm.data.osm.event.PrimitivesRemovedEvent; 035import org.openstreetmap.josm.data.osm.event.RelationMembersChangedEvent; 036import org.openstreetmap.josm.data.osm.event.TagsChangedEvent; 037import org.openstreetmap.josm.data.osm.event.WayNodesChangedEvent; 038import org.openstreetmap.josm.data.preferences.sources.ValidatorPrefHelper; 039import org.openstreetmap.josm.data.validation.OsmValidator; 040import org.openstreetmap.josm.data.validation.Severity; 041import org.openstreetmap.josm.data.validation.TestError; 042import org.openstreetmap.josm.gui.util.GuiHelper; 043import org.openstreetmap.josm.tools.AlphanumComparator; 044import org.openstreetmap.josm.tools.Destroyable; 045import org.openstreetmap.josm.tools.ListenerList; 046import org.openstreetmap.josm.tools.Pair; 047 048/** 049 * A panel that displays the error tree. The selection manager 050 * respects clicks into the selection list. Ctrl-click will remove entries from 051 * the list while single click will make the clicked entry the only selection. 052 * 053 * @author frsantos 054 */ 055public class ValidatorTreePanel extends JTree implements Destroyable, DataSetListener { 056 057 private static final class GroupTreeNode extends DefaultMutableTreeNode { 058 059 GroupTreeNode(Object userObject) { 060 super(userObject); 061 } 062 063 @Override 064 public String toString() { 065 return tr("{0} ({1})", super.toString(), getLeafCount()); 066 } 067 } 068 069 /** 070 * The validation data. 071 */ 072 protected DefaultTreeModel valTreeModel = new DefaultTreeModel(new DefaultMutableTreeNode()); 073 074 /** The list of errors shown in the tree, normally identical to field validationErrors in current edit layer*/ 075 private transient List<TestError> errors; 076 077 /** 078 * If {@link #filter} is not <code>null</code> only errors are displayed 079 * that refer to one of the primitives in the filter. 080 */ 081 private transient Set<? extends OsmPrimitive> filter; 082 083 private final transient ListenerList<Runnable> invalidationListeners = ListenerList.create(); 084 085 /** if true, buildTree() does nothing */ 086 private boolean resetScheduled; 087 088 /** 089 * Constructor 090 * @param errors The list of errors 091 */ 092 public ValidatorTreePanel(List<TestError> errors) { 093 setErrorList(errors); 094 ToolTipManager.sharedInstance().registerComponent(this); 095 this.setModel(valTreeModel); 096 this.setRootVisible(false); 097 this.setShowsRootHandles(true); 098 this.expandRow(0); 099 this.setVisibleRowCount(8); 100 this.setCellRenderer(new ValidatorTreeRenderer()); 101 this.getSelectionModel().setSelectionMode(TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION); 102 for (KeyListener keyListener : getKeyListeners()) { 103 // Fix #3596 - Remove default keyListener to avoid conflicts with JOSM commands 104 if ("javax.swing.plaf.basic.BasicTreeUI$Handler".equals(keyListener.getClass().getName())) { 105 removeKeyListener(keyListener); 106 } 107 } 108 DatasetEventManager.getInstance().addDatasetListener(this, DatasetEventManager.FireMode.IN_EDT); 109 } 110 111 @Override 112 public String getToolTipText(MouseEvent e) { 113 String res = null; 114 TreePath path = getPathForLocation(e.getX(), e.getY()); 115 if (path != null) { 116 DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent(); 117 Object nodeInfo = node.getUserObject(); 118 119 if (nodeInfo instanceof TestError) { 120 TestError error = (TestError) nodeInfo; 121 res = "<html>" + error.getNameVisitor().getText() + "<br>" + error.getMessage(); 122 String d = error.getDescription(); 123 if (d != null) 124 res += "<br>" + d; 125 res += "</html>"; 126 } else { 127 res = node.toString(); 128 } 129 } 130 return res; 131 } 132 133 /** Constructor */ 134 public ValidatorTreePanel() { 135 this(null); 136 } 137 138 @Override 139 public void setVisible(boolean v) { 140 if (v) { 141 buildTree(); 142 } else { 143 valTreeModel.setRoot(new DefaultMutableTreeNode()); 144 } 145 super.setVisible(v); 146 invalidationListeners.fireEvent(Runnable::run); 147 } 148 149 /** 150 * Builds the errors tree 151 */ 152 public void buildTree() { 153 buildTree(true); 154 } 155 156 /** 157 * Builds the errors tree 158 * @param expandAgain if true, try to expand the same rows as before 159 */ 160 public void buildTree(boolean expandAgain) { 161 if (resetScheduled) 162 return; 163 final DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode(); 164 165 if (errors == null || errors.isEmpty()) { 166 GuiHelper.runInEDTAndWait(() -> valTreeModel.setRoot(rootNode)); 167 return; 168 } 169 170 // Remember first selected tree row 171 TreePath selPath = getSelectionPath(); 172 int selRow = selPath == null ? -1 : getRowForPath(selPath); 173 174 // Remember the currently expanded rows 175 Set<Object> oldExpandedRows = new HashSet<>(); 176 if (expandAgain) { 177 Enumeration<TreePath> expanded = getExpandedDescendants(new TreePath(getRoot())); 178 if (expanded != null) { 179 while (expanded.hasMoreElements()) { 180 TreePath path = expanded.nextElement(); 181 DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent(); 182 Object userObject = node.getUserObject(); 183 if (userObject instanceof Severity) { 184 oldExpandedRows.add(userObject); 185 } else if (userObject instanceof String) { 186 String msg = removeSize((String) userObject); 187 oldExpandedRows.add(msg); 188 } 189 } 190 } 191 } 192 193 Predicate<TestError> filterToUse = e -> !e.isIgnored(); 194 if (!ValidatorPrefHelper.PREF_OTHER.get()) { 195 filterToUse = filterToUse.and(e -> e.getSeverity() != Severity.OTHER); 196 } 197 if (filter != null) { 198 filterToUse = filterToUse.and(e -> e.getPrimitives().stream().anyMatch(filter::contains)); 199 } 200 Map<Severity, Map<String, Map<String, List<TestError>>>> errorsBySeverityMessageDescription 201 = OsmValidator.getErrorsBySeverityMessageDescription(errors, filterToUse); 202 203 final List<TreePath> expandedPaths = new ArrayList<>(); 204 for (Entry<Severity, Map<String, Map<String, List<TestError>>>> entry: errorsBySeverityMessageDescription.entrySet()) { 205 Severity severity = entry.getKey(); 206 Map<String, Map<String, List<TestError>>> errorsByMessageDescription = entry.getValue(); 207 208 // Severity node 209 final DefaultMutableTreeNode severityNode = new GroupTreeNode(severity); 210 rootNode.add(severityNode); 211 212 if (oldExpandedRows.contains(severity)) { 213 expandedPaths.add(new TreePath(severityNode.getPath())); 214 } 215 216 final Map<String, List<TestError>> errorsWithEmptyMessageByDescription = errorsByMessageDescription.get(""); 217 if (errorsWithEmptyMessageByDescription != null) { 218 errorsWithEmptyMessageByDescription.forEach((description, noDescriptionErrors) -> { 219 final String msg = addSize(description, noDescriptionErrors); 220 final DefaultMutableTreeNode messageNode = new DefaultMutableTreeNode(msg); 221 severityNode.add(messageNode); 222 223 if (oldExpandedRows.contains(description)) { 224 expandedPaths.add(new TreePath(messageNode.getPath())); 225 } 226 // add the matching errors to the current node 227 noDescriptionErrors.stream().map(DefaultMutableTreeNode::new).forEach(messageNode::add); 228 }); 229 } 230 231 errorsByMessageDescription.forEach((message, errorsByDescription) -> { 232 if (message.isEmpty()) { 233 return; 234 } 235 // Group node 236 final DefaultMutableTreeNode groupNode; 237 if (errorsByDescription.size() > 1) { 238 groupNode = new GroupTreeNode(message); 239 severityNode.add(groupNode); 240 if (oldExpandedRows.contains(message)) { 241 expandedPaths.add(new TreePath(groupNode.getPath())); 242 } 243 } else { 244 groupNode = null; 245 } 246 247 errorsByDescription.forEach((description, errorsWithDescription) -> { 248 // Message node 249 final String searchMsg; 250 if (groupNode != null) { 251 searchMsg = description; 252 } else if (description == null || description.isEmpty()) { 253 searchMsg = message; 254 } else { 255 searchMsg = message + " - " + description; 256 } 257 final String msg = addSize(searchMsg, errorsWithDescription); 258 259 final DefaultMutableTreeNode messageNode = new DefaultMutableTreeNode(msg); 260 DefaultMutableTreeNode currNode = groupNode != null ? groupNode : severityNode; 261 currNode.add(messageNode); 262 if (oldExpandedRows.contains(searchMsg)) { 263 expandedPaths.add(new TreePath(messageNode.getPath())); 264 } 265 266 // add the matching errors to the current node 267 errorsWithDescription.stream().map(DefaultMutableTreeNode::new).forEach(messageNode::add); 268 }); 269 }); 270 } 271 272 valTreeModel.setRoot(rootNode); 273 for (TreePath path : expandedPaths) { 274 this.expandPath(path); 275 } 276 277 if (selPath != null) { 278 DefaultMutableTreeNode node = (DefaultMutableTreeNode) selPath.getLastPathComponent(); 279 Object userObject = node.getUserObject(); 280 if (userObject instanceof TestError && ((TestError) userObject).isIgnored()) { 281 // don't try to find ignored error 282 selPath = null; 283 } 284 } 285 if (selPath != null) { 286 // try to reselect previously selected row. May not work if tree structure changed too much. 287 DefaultMutableTreeNode node = (DefaultMutableTreeNode) selPath.getLastPathComponent(); 288 Object searchObject = node.getUserObject(); 289 String msg = null; 290 if (searchObject instanceof String) { 291 msg = removeSize((String) searchObject); 292 } 293 String searchString = msg; 294 visitTreeNodes(getRoot(), n -> { 295 boolean found = false; 296 final Object userInfo = n.getUserObject(); 297 if (searchObject instanceof TestError && userInfo instanceof TestError) { 298 TestError e1 = (TestError) searchObject; 299 TestError e2 = (TestError) userInfo; 300 found |= e1.getCode() == e2.getCode() && e1.getMessage().equals(e2.getMessage()) 301 && e1.getPrimitives().size() == e2.getPrimitives().size() 302 && e1.getPrimitives().containsAll(e2.getPrimitives()); 303 } else if (searchObject instanceof String && userInfo instanceof String) { 304 found |= ((String) userInfo).startsWith(searchString); 305 } else if (searchObject instanceof Severity) { 306 found |= searchObject.equals(userInfo); 307 } 308 309 if (found) { 310 TreePath path = new TreePath(n.getPath()); 311 setSelectionPath(path); 312 scrollPathToVisible(path); 313 } 314 }); 315 } 316 if (selRow >= 0 && selRow < getRowCount() && getSelectionCount() == 0) { 317 // fall back: if we cannot find the previously selected entry, select the row by position 318 setSelectionRow(selRow); 319 scrollRowToVisible(selRow); 320 } 321 322 invalidationListeners.fireEvent(Runnable::run); 323 } 324 325 private static String addSize(String msg, Collection<?> coll) { 326 return msg + " (" + coll.size() + ")"; 327 } 328 329 private static String removeSize(String msg) { 330 int index = msg.lastIndexOf(" ("); 331 return index > 0 ? msg.substring(0, index) : msg; 332 } 333 334 /** 335 * Sort list of errors in place (#8517). 336 */ 337 void sortErrors() { 338 if (errors.isEmpty()) 339 return; 340 // Calculate the string to sort only once for each element 341 // Avoids to call TestError.compare() which is costly 342 List<Pair<String, TestError>> toSort = new ArrayList<>(); 343 for (int i = 0; i < errors.size(); i++) { 344 TestError e = errors.get(i); 345 toSort.add(new Pair<>(e.getNameVisitor().getText(), e)); 346 } 347 toSort.sort((o1, o2) -> AlphanumComparator.getInstance().compare(o1.a, o2.a)); 348 List<TestError> sortedErrors = new ArrayList<>(errors.size()); 349 for (Pair<String, TestError> p : toSort) { 350 sortedErrors.add(p.b); 351 } 352 errors.clear(); 353 errors.addAll(sortedErrors); 354 } 355 356 /** 357 * Add a new invalidation listener 358 * @param listener The listener 359 */ 360 public void addInvalidationListener(Runnable listener) { 361 invalidationListeners.addListener(listener); 362 } 363 364 /** 365 * Remove an invalidation listener 366 * @param listener The listener 367 * @since 10880 368 */ 369 public void removeInvalidationListener(Runnable listener) { 370 invalidationListeners.removeListener(listener); 371 } 372 373 /** 374 * Sets the errors list used by a data layer 375 * @param errors The error list that is used by a data layer 376 */ 377 public final void setErrorList(List<TestError> errors) { 378 if (errors != null && errors == this.errors) 379 return; 380 this.errors = errors != null ? errors : new ArrayList<>(); 381 sortErrors(); 382 if (isVisible()) { 383 //TODO: If list is changed because another layer was activated it would be good to store/restore 384 // the expanded / selected paths. 385 clearSelection(); 386 buildTree(false); 387 } 388 } 389 390 /** 391 * Clears the current error list and adds these errors to it 392 * @param newerrors The validation errors 393 */ 394 public void setErrors(List<TestError> newerrors) { 395 errors.clear(); 396 for (TestError error : newerrors) { 397 if (!error.isIgnored()) { 398 errors.add(error); 399 } 400 } 401 sortErrors(); 402 if (isVisible()) { 403 buildTree(); 404 } 405 } 406 407 /** 408 * Returns the errors of the tree 409 * @return the errors of the tree 410 */ 411 public List<TestError> getErrors() { 412 return errors; 413 } 414 415 /** 416 * Selects all errors related to the specified {@code primitives}, i.e. where {@link TestError#getPrimitives()} 417 * returns a primitive present in {@code primitives}. 418 * @param primitives collection of primitives 419 */ 420 public void selectRelatedErrors(final Collection<OsmPrimitive> primitives) { 421 final List<TreePath> paths = new ArrayList<>(); 422 walkAndSelectRelatedErrors(new TreePath(getRoot()), new HashSet<>(primitives)::contains, paths); 423 clearSelection(); 424 setSelectionPaths(paths.toArray(new TreePath[0])); 425 // make sure that first path is visible 426 if (!paths.isEmpty()) { 427 scrollPathToVisible(paths.get(0)); 428 } 429 } 430 431 private void walkAndSelectRelatedErrors(final TreePath p, final Predicate<OsmPrimitive> isRelevant, final Collection<TreePath> paths) { 432 final int count = getModel().getChildCount(p.getLastPathComponent()); 433 for (int i = 0; i < count; i++) { 434 final Object child = getModel().getChild(p.getLastPathComponent(), i); 435 if (getModel().isLeaf(child) && child instanceof DefaultMutableTreeNode 436 && ((DefaultMutableTreeNode) child).getUserObject() instanceof TestError) { 437 final TestError error = (TestError) ((DefaultMutableTreeNode) child).getUserObject(); 438 if (error.getPrimitives().stream().anyMatch(isRelevant)) { 439 paths.add(p.pathByAddingChild(child)); 440 } 441 } else { 442 walkAndSelectRelatedErrors(p.pathByAddingChild(child), isRelevant, paths); 443 } 444 } 445 } 446 447 /** 448 * Returns the filter list 449 * @return the list of primitives used for filtering 450 */ 451 public Set<? extends OsmPrimitive> getFilter() { 452 return filter; 453 } 454 455 /** 456 * Set the filter list to a set of primitives 457 * @param filter the list of primitives used for filtering 458 */ 459 public void setFilter(Set<? extends OsmPrimitive> filter) { 460 if (filter != null && filter.isEmpty()) { 461 this.filter = null; 462 } else { 463 this.filter = filter; 464 } 465 if (isVisible()) { 466 buildTree(); 467 } 468 } 469 470 /** 471 * Updates the current errors list 472 */ 473 public void resetErrors() { 474 resetScheduled = false; 475 filterRemovedPrimitives(); 476 setErrors(new ArrayList<>(errors)); 477 } 478 479 /** 480 * Expands complete tree 481 */ 482 public void expandAll() { 483 visitTreeNodes(getRoot(), x -> expandPath(new TreePath(x.getPath()))); 484 } 485 486 /** 487 * Returns the root node model. 488 * @return The root node model 489 */ 490 public DefaultMutableTreeNode getRoot() { 491 return (DefaultMutableTreeNode) valTreeModel.getRoot(); 492 } 493 494 @Override 495 public void destroy() { 496 DatasetEventManager.getInstance().removeDatasetListener(this); 497 ToolTipManager.sharedInstance().unregisterComponent(this); 498 errors.clear(); 499 } 500 501 /** 502 * Visitor call for all tree nodes children of root, in breadth-first order. 503 * @param root Root node 504 * @param visitor Visitor 505 * @since 13940 506 */ 507 public static void visitTreeNodes(DefaultMutableTreeNode root, Consumer<DefaultMutableTreeNode> visitor) { 508 @SuppressWarnings("unchecked") 509 Enumeration<TreeNode> errorMessages = root.breadthFirstEnumeration(); 510 while (errorMessages.hasMoreElements()) { 511 visitor.accept(((DefaultMutableTreeNode) errorMessages.nextElement())); 512 } 513 } 514 515 /** 516 * Visitor call for all {@link TestError} nodes children of root, in breadth-first order. 517 * @param root Root node 518 * @param visitor Visitor 519 * @since 13940 520 */ 521 public static void visitTestErrors(DefaultMutableTreeNode root, Consumer<TestError> visitor) { 522 visitTestErrors(root, visitor, null); 523 } 524 525 /** 526 * Visitor call for all {@link TestError} nodes children of root, in breadth-first order. 527 * @param root Root node 528 * @param visitor Visitor 529 * @param processedNodes Set of already visited nodes (optional) 530 * @since 13940 531 */ 532 public static void visitTestErrors(DefaultMutableTreeNode root, Consumer<TestError> visitor, 533 Set<DefaultMutableTreeNode> processedNodes) { 534 visitTreeNodes(root, n -> { 535 if (processedNodes == null || !processedNodes.contains(n)) { 536 if (processedNodes != null) { 537 processedNodes.add(n); 538 } 539 Object o = n.getUserObject(); 540 if (o instanceof TestError) { 541 visitor.accept((TestError) o); 542 } 543 } 544 }); 545 } 546 547 @Override public void primitivesRemoved(PrimitivesRemovedEvent event) { 548 // Remove purged primitives (fix #8639) 549 if (filterRemovedPrimitives()) { 550 buildTree(); 551 } 552 } 553 554 @Override public void primitivesAdded(PrimitivesAddedEvent event) { 555 // Do nothing 556 } 557 558 @Override public void tagsChanged(TagsChangedEvent event) { 559 // Do nothing 560 } 561 562 @Override public void nodeMoved(NodeMovedEvent event) { 563 // Do nothing 564 } 565 566 @Override public void wayNodesChanged(WayNodesChangedEvent event) { 567 // Do nothing 568 } 569 570 @Override public void relationMembersChanged(RelationMembersChangedEvent event) { 571 // Do nothing 572 } 573 574 @Override public void otherDatasetChange(AbstractDatasetChangedEvent event) { 575 // Do nothing 576 } 577 578 @Override public void dataChanged(DataChangedEvent event) { 579 if (filterRemovedPrimitives()) { 580 buildTree(); 581 } 582 } 583 584 /** 585 * Can be called to suppress execution of buildTree() while doing multiple updates. Caller must 586 * call resetErrors() to end this state. 587 * @since 14849 588 */ 589 public void setResetScheduled() { 590 resetScheduled = true; 591 } 592 593 /** 594 * Remove errors which refer to removed or purged primitives. 595 * @return true if error list was changed 596 */ 597 private boolean filterRemovedPrimitives() { 598 return errors.removeIf( 599 error -> error.getPrimitives().stream().anyMatch(p -> p.isDeleted() || p.getDataSet() == null)); 600 } 601 602}