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