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