001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.dialogs; 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; 007import static org.openstreetmap.josm.tools.I18n.trn; 008 009import java.awt.Color; 010import java.awt.Graphics; 011import java.awt.Point; 012import java.awt.event.ActionEvent; 013import java.awt.event.KeyEvent; 014import java.awt.event.MouseEvent; 015import java.util.ArrayList; 016import java.util.Arrays; 017import java.util.Collection; 018import java.util.HashSet; 019import java.util.LinkedList; 020import java.util.List; 021import java.util.Set; 022import java.util.concurrent.CopyOnWriteArrayList; 023import java.util.stream.IntStream; 024 025import javax.swing.AbstractAction; 026import javax.swing.JList; 027import javax.swing.JMenuItem; 028import javax.swing.JOptionPane; 029import javax.swing.JPopupMenu; 030import javax.swing.ListModel; 031import javax.swing.ListSelectionModel; 032import javax.swing.event.ListDataEvent; 033import javax.swing.event.ListDataListener; 034import javax.swing.event.ListSelectionEvent; 035import javax.swing.event.ListSelectionListener; 036import javax.swing.event.PopupMenuEvent; 037import javax.swing.event.PopupMenuListener; 038 039import org.openstreetmap.josm.actions.AbstractSelectAction; 040import org.openstreetmap.josm.actions.AutoScaleAction; 041import org.openstreetmap.josm.actions.ExpertToggleAction; 042import org.openstreetmap.josm.command.Command; 043import org.openstreetmap.josm.command.SequenceCommand; 044import org.openstreetmap.josm.data.UndoRedoHandler; 045import org.openstreetmap.josm.data.conflict.Conflict; 046import org.openstreetmap.josm.data.conflict.ConflictCollection; 047import org.openstreetmap.josm.data.conflict.IConflictListener; 048import org.openstreetmap.josm.data.osm.DataSelectionListener; 049import org.openstreetmap.josm.data.osm.DataSet; 050import org.openstreetmap.josm.data.osm.Node; 051import org.openstreetmap.josm.data.osm.OsmPrimitive; 052import org.openstreetmap.josm.data.osm.Relation; 053import org.openstreetmap.josm.data.osm.RelationMember; 054import org.openstreetmap.josm.data.osm.Way; 055import org.openstreetmap.josm.data.osm.visitor.OsmPrimitiveVisitor; 056import org.openstreetmap.josm.data.preferences.NamedColorProperty; 057import org.openstreetmap.josm.gui.HelpAwareOptionPane; 058import org.openstreetmap.josm.gui.HelpAwareOptionPane.ButtonSpec; 059import org.openstreetmap.josm.gui.MainApplication; 060import org.openstreetmap.josm.gui.NavigatableComponent; 061import org.openstreetmap.josm.gui.PopupMenuHandler; 062import org.openstreetmap.josm.gui.PrimitiveRenderer; 063import org.openstreetmap.josm.gui.SideButton; 064import org.openstreetmap.josm.gui.conflict.pair.ConflictResolver; 065import org.openstreetmap.josm.gui.conflict.pair.MergeDecisionType; 066import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeEvent; 067import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeListener; 068import org.openstreetmap.josm.gui.layer.OsmDataLayer; 069import org.openstreetmap.josm.gui.util.GuiHelper; 070import org.openstreetmap.josm.gui.widgets.PopupMenuLauncher; 071import org.openstreetmap.josm.tools.ImageProvider; 072import org.openstreetmap.josm.tools.Logging; 073import org.openstreetmap.josm.tools.Shortcut; 074 075/** 076 * This dialog displays the {@link ConflictCollection} of the active {@link OsmDataLayer} in a toggle 077 * dialog on the right of the main frame. 078 * @since 86 079 */ 080public final class ConflictDialog extends ToggleDialog implements ActiveLayerChangeListener, IConflictListener, DataSelectionListener { 081 082 private static final NamedColorProperty CONFLICT_COLOR = new NamedColorProperty(marktr("conflict"), Color.GRAY); 083 private static final NamedColorProperty BACKGROUND_COLOR = new NamedColorProperty(marktr("background"), Color.BLACK); 084 085 /** the collection of conflicts displayed by this conflict dialog */ 086 private transient ConflictCollection conflicts; 087 088 /** the model for the list of conflicts */ 089 private transient ConflictListModel model; 090 /** the list widget for the list of conflicts */ 091 private JList<OsmPrimitive> lstConflicts; 092 093 private final JPopupMenu popupMenu = new JPopupMenu(); 094 private final transient PopupMenuHandler popupMenuHandler = new PopupMenuHandler(popupMenu); 095 096 private final ResolveAction actResolve = new ResolveAction(); 097 private final SelectAction actSelect = new SelectAction(); 098 099 /** 100 * Constructs a new {@code ConflictDialog}. 101 */ 102 public ConflictDialog() { 103 super(tr("Conflict"), "conflict", tr("Resolve conflicts"), 104 Shortcut.registerShortcut("subwindow:conflict", tr("Windows: {0}", tr("Conflict")), 105 KeyEvent.VK_C, Shortcut.ALT_SHIFT), 100); 106 107 build(); 108 refreshView(); 109 } 110 111 /** 112 * Replies the color used to paint conflicts. 113 * 114 * @return the color used to paint conflicts 115 * @see #paintConflicts 116 * @since 1221 117 */ 118 public static Color getColor() { 119 return CONFLICT_COLOR.get(); 120 } 121 122 /** 123 * builds the GUI 124 */ 125 private void build() { 126 synchronized (this) { 127 model = new ConflictListModel(); 128 129 lstConflicts = new JList<>(model); 130 lstConflicts.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); 131 lstConflicts.setCellRenderer(new PrimitiveRenderer()); 132 lstConflicts.addMouseListener(new MouseEventHandler()); 133 } 134 addListSelectionListener(e -> MainApplication.getMap().mapView.repaint()); 135 136 SideButton btnResolve = new SideButton(actResolve); 137 addListSelectionListener(actResolve); 138 139 SideButton btnSelect = new SideButton(actSelect); 140 addListSelectionListener(actSelect); 141 142 createLayout(lstConflicts, true, Arrays.asList(btnResolve, btnSelect)); 143 144 popupMenuHandler.addAction(MainApplication.getMenu().autoScaleActions.get(AutoScaleAction.AutoScaleMode.CONFLICT)); 145 146 ResolveToMyVersionAction resolveToMyVersionAction = new ResolveToMyVersionAction(); 147 ResolveToTheirVersionAction resolveToTheirVersionAction = new ResolveToTheirVersionAction(); 148 addListSelectionListener(resolveToMyVersionAction); 149 addListSelectionListener(resolveToTheirVersionAction); 150 JMenuItem btnResolveMy = popupMenuHandler.addAction(resolveToMyVersionAction); 151 JMenuItem btnResolveTheir = popupMenuHandler.addAction(resolveToTheirVersionAction); 152 153 popupMenuHandler.addListener(new ResolveButtonsPopupMenuListener(btnResolveTheir, btnResolveMy)); 154 } 155 156 @Override 157 public void showNotify() { 158 MainApplication.getLayerManager().addAndFireActiveLayerChangeListener(this); 159 } 160 161 @Override 162 public void hideNotify() { 163 MainApplication.getLayerManager().removeActiveLayerChangeListener(this); 164 removeDataLayerListeners(MainApplication.getLayerManager().getEditLayer()); 165 } 166 167 /** 168 * Add a list selection listener to the conflicts list. 169 * @param listener the ListSelectionListener 170 * @since 5958 171 */ 172 public synchronized void addListSelectionListener(ListSelectionListener listener) { 173 lstConflicts.getSelectionModel().addListSelectionListener(listener); 174 } 175 176 /** 177 * Remove the given list selection listener from the conflicts list. 178 * @param listener the ListSelectionListener 179 * @since 5958 180 */ 181 public synchronized void removeListSelectionListener(ListSelectionListener listener) { 182 lstConflicts.getSelectionModel().removeListSelectionListener(listener); 183 } 184 185 /** 186 * Replies the popup menu handler. 187 * @return The popup menu handler 188 * @since 5958 189 */ 190 public PopupMenuHandler getPopupMenuHandler() { 191 return popupMenuHandler; 192 } 193 194 /** 195 * Launches a conflict resolution dialog for the first selected conflict 196 */ 197 private void resolve() { 198 synchronized (this) { 199 if (conflicts == null || model.getSize() == 0) 200 return; 201 202 int index = lstConflicts.getSelectedIndex(); 203 if (index < 0) { 204 index = 0; 205 } 206 207 Conflict<? extends OsmPrimitive> c = conflicts.get(index); 208 ConflictResolutionDialog dialog = new ConflictResolutionDialog(MainApplication.getMainFrame()); 209 dialog.getConflictResolver().populate(c); 210 dialog.showDialog(); 211 212 if (index < conflicts.size() - 1) { 213 lstConflicts.setSelectedIndex(index); 214 } else { 215 lstConflicts.setSelectedIndex(index - 1); 216 } 217 } 218 MainApplication.getMap().mapView.repaint(); 219 } 220 221 /** 222 * refreshes the view of this dialog 223 */ 224 public void refreshView() { 225 DataSet editDs = MainApplication.getLayerManager().getEditDataSet(); 226 synchronized (this) { 227 conflicts = editDs == null ? new ConflictCollection() : editDs.getConflicts(); 228 } 229 GuiHelper.runInEDT(() -> { 230 model.fireContentChanged(); 231 updateTitle(); 232 }); 233 } 234 235 private synchronized void updateTitle() { 236 int conflictsCount = conflicts.size(); 237 if (conflictsCount > 0) { 238 setTitle(trn("Conflict: {0} unresolved", "Conflicts: {0} unresolved", conflictsCount, conflictsCount) + 239 " ("+tr("Rel.:{0} / Ways:{1} / Nodes:{2}", 240 conflicts.getNumberOfRelationConflicts(), 241 conflicts.getNumberOfWayConflicts(), 242 conflicts.getNumberOfNodeConflicts())+')'); 243 } else { 244 setTitle(tr("Conflict")); 245 } 246 } 247 248 /** 249 * Paints all conflicts that can be expressed on the main window. 250 * 251 * @param g The {@code Graphics} used to paint 252 * @param nc The {@code NavigatableComponent} used to get screen coordinates of nodes 253 * @since 86 254 */ 255 public void paintConflicts(final Graphics g, final NavigatableComponent nc) { 256 Color preferencesColor = getColor(); 257 if (preferencesColor.equals(BACKGROUND_COLOR.get())) 258 return; 259 g.setColor(preferencesColor); 260 OsmPrimitiveVisitor conflictPainter = new ConflictPainter(nc, g); 261 synchronized (this) { 262 for (OsmPrimitive o : lstConflicts.getSelectedValuesList()) { 263 if (conflicts == null || !conflicts.hasConflictForMy(o)) { 264 continue; 265 } 266 conflicts.getConflictForMy(o).getTheir().accept(conflictPainter); 267 } 268 } 269 } 270 271 @Override 272 public void activeOrEditLayerChanged(ActiveLayerChangeEvent e) { 273 removeDataLayerListeners(e.getPreviousDataLayer()); 274 addDataLayerListeners(e.getSource().getActiveDataLayer()); 275 refreshView(); 276 } 277 278 private void addDataLayerListeners(OsmDataLayer newLayer) { 279 if (newLayer != null) { 280 newLayer.getConflicts().addConflictListener(this); 281 newLayer.data.addSelectionListener(this); 282 } 283 } 284 285 private void removeDataLayerListeners(OsmDataLayer oldLayer) { 286 if (oldLayer != null) { 287 oldLayer.getConflicts().removeConflictListener(this); 288 oldLayer.data.removeSelectionListener(this); 289 } 290 } 291 292 /** 293 * replies the conflict collection currently held by this dialog; may be null 294 * 295 * @return the conflict collection currently held by this dialog; may be null 296 */ 297 public synchronized ConflictCollection getConflicts() { 298 return conflicts; 299 } 300 301 /** 302 * returns the first selected item of the conflicts list 303 * 304 * @return Conflict 305 */ 306 public synchronized Conflict<? extends OsmPrimitive> getSelectedConflict() { 307 if (conflicts == null || model.getSize() == 0) 308 return null; 309 310 int index = lstConflicts.getSelectedIndex(); 311 312 return index >= 0 && index < conflicts.size() ? conflicts.get(index) : null; 313 } 314 315 private synchronized boolean isConflictSelected() { 316 final ListSelectionModel selModel = lstConflicts.getSelectionModel(); 317 return selModel.getMinSelectionIndex() >= 0 && selModel.getMaxSelectionIndex() >= selModel.getMinSelectionIndex(); 318 } 319 320 @Override 321 public void onConflictsAdded(ConflictCollection conflicts) { 322 refreshView(); 323 } 324 325 @Override 326 public void onConflictsRemoved(ConflictCollection conflicts) { 327 Logging.debug("1 conflict has been resolved."); 328 refreshView(); 329 } 330 331 @Override 332 public synchronized void selectionChanged(SelectionChangeEvent event) { 333 lstConflicts.setValueIsAdjusting(true); 334 lstConflicts.clearSelection(); 335 for (OsmPrimitive osm : event.getSelection()) { 336 if (conflicts != null && conflicts.hasConflictForMy(osm)) { 337 int pos = model.indexOf(osm); 338 if (pos >= 0) { 339 lstConflicts.addSelectionInterval(pos, pos); 340 } 341 } 342 } 343 lstConflicts.setValueIsAdjusting(false); 344 } 345 346 @Override 347 public String helpTopic() { 348 return ht("/Dialog/ConflictList"); 349 } 350 351 static final class ResolveButtonsPopupMenuListener implements PopupMenuListener { 352 private final JMenuItem btnResolveTheir; 353 private final JMenuItem btnResolveMy; 354 355 ResolveButtonsPopupMenuListener(JMenuItem btnResolveTheir, JMenuItem btnResolveMy) { 356 this.btnResolveTheir = btnResolveTheir; 357 this.btnResolveMy = btnResolveMy; 358 } 359 360 @Override 361 public void popupMenuWillBecomeVisible(PopupMenuEvent e) { 362 btnResolveMy.setVisible(ExpertToggleAction.isExpert()); 363 btnResolveTheir.setVisible(ExpertToggleAction.isExpert()); 364 } 365 366 @Override 367 public void popupMenuWillBecomeInvisible(PopupMenuEvent e) { 368 // Do nothing 369 } 370 371 @Override 372 public void popupMenuCanceled(PopupMenuEvent e) { 373 // Do nothing 374 } 375 } 376 377 class MouseEventHandler extends PopupMenuLauncher { 378 /** 379 * Constructs a new {@code MouseEventHandler}. 380 */ 381 MouseEventHandler() { 382 super(popupMenu); 383 } 384 385 @Override public void mouseClicked(MouseEvent e) { 386 if (isDoubleClick(e)) { 387 resolve(); 388 } 389 } 390 } 391 392 /** 393 * The {@link ListModel} for conflicts 394 * 395 */ 396 class ConflictListModel implements ListModel<OsmPrimitive> { 397 398 private final CopyOnWriteArrayList<ListDataListener> listeners; 399 400 /** 401 * Constructs a new {@code ConflictListModel}. 402 */ 403 ConflictListModel() { 404 listeners = new CopyOnWriteArrayList<>(); 405 } 406 407 @Override 408 public void addListDataListener(ListDataListener l) { 409 if (l != null) { 410 listeners.addIfAbsent(l); 411 } 412 } 413 414 @Override 415 public void removeListDataListener(ListDataListener l) { 416 listeners.remove(l); 417 } 418 419 protected void fireContentChanged() { 420 ListDataEvent evt = new ListDataEvent( 421 this, 422 ListDataEvent.CONTENTS_CHANGED, 423 0, 424 getSize() 425 ); 426 for (ListDataListener listener : listeners) { 427 listener.contentsChanged(evt); 428 } 429 } 430 431 @Override 432 public synchronized OsmPrimitive getElementAt(int index) { 433 if (index < 0 || index >= getSize()) 434 return null; 435 return conflicts.get(index).getMy(); 436 } 437 438 @Override 439 public synchronized int getSize() { 440 return conflicts != null ? conflicts.size() : 0; 441 } 442 443 public synchronized int indexOf(OsmPrimitive my) { 444 if (conflicts != null) { 445 return IntStream.range(0, conflicts.size()) 446 .filter(i -> conflicts.get(i).isMatchingMy(my)) 447 .findFirst().orElse(-1); 448 } 449 return -1; 450 } 451 452 public synchronized OsmPrimitive get(int idx) { 453 return conflicts != null ? conflicts.get(idx).getMy() : null; 454 } 455 } 456 457 class ResolveAction extends AbstractAction implements ListSelectionListener { 458 ResolveAction() { 459 putValue(NAME, tr("Resolve")); 460 putValue(SHORT_DESCRIPTION, tr("Open a merge dialog of all selected items in the list above.")); 461 new ImageProvider("dialogs", "conflict").getResource().attachImageIcon(this, true); 462 putValue("help", ht("/Dialog/ConflictList#ResolveAction")); 463 } 464 465 @Override 466 public void actionPerformed(ActionEvent e) { 467 resolve(); 468 } 469 470 @Override 471 public void valueChanged(ListSelectionEvent e) { 472 setEnabled(isConflictSelected()); 473 } 474 } 475 476 final class SelectAction extends AbstractSelectAction implements ListSelectionListener { 477 private SelectAction() { 478 putValue("help", ht("/Dialog/ConflictList#SelectAction")); 479 } 480 481 @Override 482 public void actionPerformed(ActionEvent e) { 483 Collection<OsmPrimitive> sel = new LinkedList<>(); 484 synchronized (this) { 485 sel.addAll(lstConflicts.getSelectedValuesList()); 486 } 487 DataSet ds = MainApplication.getLayerManager().getEditDataSet(); 488 if (ds != null) { // Can't see how it is possible but it happened in #7942 489 ds.setSelected(sel); 490 } 491 } 492 493 @Override 494 public void valueChanged(ListSelectionEvent e) { 495 setEnabled(isConflictSelected()); 496 } 497 } 498 499 abstract class ResolveToAction extends ResolveAction { 500 private final String name; 501 private final MergeDecisionType type; 502 503 ResolveToAction(String name, String description, MergeDecisionType type) { 504 this.name = name; 505 this.type = type; 506 putValue(NAME, name); 507 putValue(SHORT_DESCRIPTION, description); 508 } 509 510 @Override 511 public void actionPerformed(ActionEvent e) { 512 final ConflictResolver resolver = new ConflictResolver(); 513 final List<Command> commands = new ArrayList<>(); 514 synchronized (this) { 515 for (OsmPrimitive osmPrimitive : lstConflicts.getSelectedValuesList()) { 516 Conflict<? extends OsmPrimitive> c = conflicts.getConflictForMy(osmPrimitive); 517 if (c != null) { 518 resolver.populate(c); 519 resolver.decideRemaining(type); 520 commands.add(resolver.buildResolveCommand()); 521 } 522 } 523 } 524 UndoRedoHandler.getInstance().add(new SequenceCommand(name, commands)); 525 refreshView(); 526 } 527 } 528 529 class ResolveToMyVersionAction extends ResolveToAction { 530 ResolveToMyVersionAction() { 531 super(tr("Resolve to my versions"), tr("Resolves all unresolved conflicts to ''my'' version"), 532 MergeDecisionType.KEEP_MINE); 533 } 534 } 535 536 class ResolveToTheirVersionAction extends ResolveToAction { 537 ResolveToTheirVersionAction() { 538 super(tr("Resolve to their versions"), tr("Resolves all unresolved conflicts to ''their'' version"), 539 MergeDecisionType.KEEP_THEIR); 540 } 541 } 542 543 /** 544 * Paints conflicts. 545 */ 546 public static class ConflictPainter implements OsmPrimitiveVisitor { 547 // Manage a stack of visited relations to avoid infinite recursion with cyclic relations (fix #7938) 548 private final Set<Relation> visited = new HashSet<>(); 549 private final NavigatableComponent nc; 550 private final Graphics g; 551 552 ConflictPainter(NavigatableComponent nc, Graphics g) { 553 this.nc = nc; 554 this.g = g; 555 } 556 557 @Override 558 public void visit(Node n) { 559 Point p = nc.getPoint(n); 560 g.drawRect(p.x-1, p.y-1, 2, 2); 561 } 562 563 private void visit(Node n1, Node n2) { 564 Point p1 = nc.getPoint(n1); 565 Point p2 = nc.getPoint(n2); 566 g.drawLine(p1.x, p1.y, p2.x, p2.y); 567 } 568 569 @Override 570 public void visit(Way w) { 571 Node lastN = null; 572 for (Node n : w.getNodes()) { 573 if (lastN == null) { 574 lastN = n; 575 continue; 576 } 577 visit(lastN, n); 578 lastN = n; 579 } 580 } 581 582 @Override 583 public void visit(Relation e) { 584 if (!visited.contains(e)) { 585 visited.add(e); 586 try { 587 for (RelationMember em : e.getMembers()) { 588 em.getMember().accept(this); 589 } 590 } finally { 591 visited.remove(e); 592 } 593 } 594 } 595 } 596 597 /** 598 * Warns the user about the number of detected conflicts 599 * 600 * @param numNewConflicts the number of detected conflicts 601 * @since 5775 602 */ 603 public void warnNumNewConflicts(int numNewConflicts) { 604 if (numNewConflicts == 0) 605 return; 606 607 String msg1 = trn( 608 "There was {0} conflict detected.", 609 "There were {0} conflicts detected.", 610 numNewConflicts, 611 numNewConflicts 612 ); 613 614 final StringBuilder sb = new StringBuilder(); 615 sb.append("<html>").append(msg1).append("</html>"); 616 if (numNewConflicts > 0) { 617 final ButtonSpec[] options = { 618 new ButtonSpec( 619 tr("OK"), 620 new ImageProvider("ok"), 621 tr("Click to close this dialog and continue editing"), 622 null /* no specific help */ 623 ) 624 }; 625 GuiHelper.runInEDT(() -> { 626 HelpAwareOptionPane.showOptionDialog( 627 MainApplication.getMainFrame(), 628 sb.toString(), 629 tr("Conflicts detected"), 630 JOptionPane.WARNING_MESSAGE, 631 null, /* no icon */ 632 options, 633 options[0], 634 ht("/Concepts/Conflict#WarningAboutDetectedConflicts") 635 ); 636 unfurlDialog(); 637 MainApplication.getMap().repaint(); 638 }); 639 } 640 } 641}