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.info("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 = new ButtonSpec[] {
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}