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}