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