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}