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