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        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        Main.getLayerManager().addAndFireActiveLayerChangeListener(this);
173        refreshView();
174    }
175
176    @Override
177    public void hideNotify() {
178        Main.getLayerManager().removeActiveLayerChangeListener(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.showDialog();
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.getLayerManager().getEditLayer();
236        conflicts = editLayer == null ? new ConflictCollection() : editLayer.getConflicts();
237        GuiHelper.runInEDT(() -> {
238            model.fireContentChanged();
239            updateTitle();
240        });
241    }
242
243    private void updateTitle() {
244        int conflictsCount = conflicts.size();
245        if (conflictsCount > 0) {
246            setTitle(trn("Conflict: {0} unresolved", "Conflicts: {0} unresolved", conflictsCount, conflictsCount) +
247                    " ("+tr("Rel.:{0} / Ways:{1} / Nodes:{2}",
248                            conflicts.getRelationConflicts().size(),
249                            conflicts.getWayConflicts().size(),
250                            conflicts.getNodeConflicts().size())+')');
251        } else {
252            setTitle(tr("Conflict"));
253        }
254    }
255
256    /**
257     * Paints all conflicts that can be expressed on the main window.
258     *
259     * @param g The {@code Graphics} used to paint
260     * @param nc The {@code NavigatableComponent} used to get screen coordinates of nodes
261     * @since 86
262     */
263    public void paintConflicts(final Graphics g, final NavigatableComponent nc) {
264        Color preferencesColor = getColor();
265        if (preferencesColor.equals(BACKGROUND_COLOR.get()))
266            return;
267        g.setColor(preferencesColor);
268        Visitor conflictPainter = new ConflictPainter(nc, g);
269        for (OsmPrimitive o : lstConflicts.getSelectedValuesList()) {
270            if (conflicts == null || !conflicts.hasConflictForMy(o)) {
271                continue;
272            }
273            conflicts.getConflictForMy(o).getTheir().accept(conflictPainter);
274        }
275    }
276
277    @Override
278    public void activeOrEditLayerChanged(ActiveLayerChangeEvent e) {
279        OsmDataLayer oldLayer = e.getPreviousEditLayer();
280        if (oldLayer != null) {
281            oldLayer.getConflicts().removeConflictListener(this);
282        }
283        OsmDataLayer newLayer = e.getSource().getEditLayer();
284        if (newLayer != null) {
285            newLayer.getConflicts().addConflictListener(this);
286        }
287        refreshView();
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 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 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 ? conflicts.get(index) : null;
311    }
312
313    private 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        Main.info("1 conflict has been resolved.");
326        refreshView();
327    }
328
329    @Override
330    public void selectionChanged(Collection<? extends OsmPrimitive> newSelection) {
331        lstConflicts.clearSelection();
332        for (OsmPrimitive osm : newSelection) {
333            if (conflicts != null && conflicts.hasConflictForMy(osm)) {
334                int pos = model.indexOf(osm);
335                if (pos >= 0) {
336                    lstConflicts.addSelectionInterval(pos, pos);
337                }
338            }
339        }
340    }
341
342    @Override
343    public String helpTopic() {
344        return ht("/Dialog/ConflictList");
345    }
346
347    class MouseEventHandler extends PopupMenuLauncher {
348        /**
349         * Constructs a new {@code MouseEventHandler}.
350         */
351        MouseEventHandler() {
352            super(popupMenu);
353        }
354
355        @Override public void mouseClicked(MouseEvent e) {
356            if (isDoubleClick(e)) {
357                resolve();
358            }
359        }
360    }
361
362    /**
363     * The {@link ListModel} for conflicts
364     *
365     */
366    class ConflictListModel implements ListModel<OsmPrimitive> {
367
368        private final CopyOnWriteArrayList<ListDataListener> listeners;
369
370        /**
371         * Constructs a new {@code ConflictListModel}.
372         */
373        ConflictListModel() {
374            listeners = new CopyOnWriteArrayList<>();
375        }
376
377        @Override
378        public void addListDataListener(ListDataListener l) {
379            if (l != null) {
380                listeners.addIfAbsent(l);
381            }
382        }
383
384        @Override
385        public void removeListDataListener(ListDataListener l) {
386            listeners.remove(l);
387        }
388
389        protected void fireContentChanged() {
390            ListDataEvent evt = new ListDataEvent(
391                    this,
392                    ListDataEvent.CONTENTS_CHANGED,
393                    0,
394                    getSize()
395            );
396            for (ListDataListener listener : listeners) {
397                listener.contentsChanged(evt);
398            }
399        }
400
401        @Override
402        public OsmPrimitive getElementAt(int index) {
403            if (index < 0 || index >= getSize())
404                return null;
405            return conflicts.get(index).getMy();
406        }
407
408        @Override
409        public int getSize() {
410            return conflicts != null ? conflicts.size() : 0;
411        }
412
413        public int indexOf(OsmPrimitive my) {
414            if (conflicts != null) {
415                for (int i = 0; i < conflicts.size(); i++) {
416                    if (conflicts.get(i).isMatchingMy(my))
417                        return i;
418                }
419            }
420            return -1;
421        }
422
423        public OsmPrimitive get(int idx) {
424            return conflicts != null ? conflicts.get(idx).getMy() : null;
425        }
426    }
427
428    class ResolveAction extends AbstractAction implements ListSelectionListener {
429        ResolveAction() {
430            putValue(NAME, tr("Resolve"));
431            putValue(SHORT_DESCRIPTION, tr("Open a merge dialog of all selected items in the list above."));
432            new ImageProvider("dialogs", "conflict").getResource().attachImageIcon(this, true);
433            putValue("help", ht("/Dialog/ConflictList#ResolveAction"));
434        }
435
436        @Override
437        public void actionPerformed(ActionEvent e) {
438            resolve();
439        }
440
441        @Override
442        public void valueChanged(ListSelectionEvent e) {
443            setEnabled(isConflictSelected());
444        }
445    }
446
447    final class SelectAction extends AbstractSelectAction implements ListSelectionListener {
448        private SelectAction() {
449            putValue("help", ht("/Dialog/ConflictList#SelectAction"));
450        }
451
452        @Override
453        public void actionPerformed(ActionEvent e) {
454            Collection<OsmPrimitive> sel = new LinkedList<>();
455            for (OsmPrimitive o : lstConflicts.getSelectedValuesList()) {
456                sel.add(o);
457            }
458            DataSet ds = Main.getLayerManager().getEditDataSet();
459            if (ds != null) { // Can't see how it is possible but it happened in #7942
460                ds.setSelected(sel);
461            }
462        }
463
464        @Override
465        public void valueChanged(ListSelectionEvent e) {
466            setEnabled(isConflictSelected());
467        }
468    }
469
470    abstract class ResolveToAction extends ResolveAction {
471        private final String name;
472        private final MergeDecisionType type;
473
474        ResolveToAction(String name, String description, MergeDecisionType type) {
475            this.name = name;
476            this.type = type;
477            putValue(NAME, name);
478            putValue(SHORT_DESCRIPTION, description);
479        }
480
481        @Override
482        public void actionPerformed(ActionEvent e) {
483            final ConflictResolver resolver = new ConflictResolver();
484            final List<Command> commands = new ArrayList<>();
485            for (OsmPrimitive osmPrimitive : lstConflicts.getSelectedValuesList()) {
486                Conflict<? extends OsmPrimitive> c = conflicts.getConflictForMy(osmPrimitive);
487                if (c != null) {
488                    resolver.populate(c);
489                    resolver.decideRemaining(type);
490                    commands.add(resolver.buildResolveCommand());
491                }
492            }
493            Main.main.undoRedo.add(new SequenceCommand(name, commands));
494            refreshView();
495            Main.map.mapView.repaint();
496        }
497    }
498
499    class ResolveToMyVersionAction extends ResolveToAction {
500        ResolveToMyVersionAction() {
501            super(tr("Resolve to my versions"), tr("Resolves all unresolved conflicts to ''my'' version"),
502                    MergeDecisionType.KEEP_MINE);
503        }
504    }
505
506    class ResolveToTheirVersionAction extends ResolveToAction {
507        ResolveToTheirVersionAction() {
508            super(tr("Resolve to their versions"), tr("Resolves all unresolved conflicts to ''their'' version"),
509                    MergeDecisionType.KEEP_THEIR);
510        }
511    }
512
513    /**
514     * Paints conflicts.
515     */
516    public static class ConflictPainter extends AbstractVisitor {
517        // Manage a stack of visited relations to avoid infinite recursion with cyclic relations (fix #7938)
518        private final Set<Relation> visited = new HashSet<>();
519        private final NavigatableComponent nc;
520        private final Graphics g;
521
522        ConflictPainter(NavigatableComponent nc, Graphics g) {
523            this.nc = nc;
524            this.g = g;
525        }
526
527        @Override
528        public void visit(Node n) {
529            Point p = nc.getPoint(n);
530            g.drawRect(p.x-1, p.y-1, 2, 2);
531        }
532
533        private void visit(Node n1, Node n2) {
534            Point p1 = nc.getPoint(n1);
535            Point p2 = nc.getPoint(n2);
536            g.drawLine(p1.x, p1.y, p2.x, p2.y);
537        }
538
539        @Override
540        public void visit(Way w) {
541            Node lastN = null;
542            for (Node n : w.getNodes()) {
543                if (lastN == null) {
544                    lastN = n;
545                    continue;
546                }
547                visit(lastN, n);
548                lastN = n;
549            }
550        }
551
552        @Override
553        public void visit(Relation e) {
554            if (!visited.contains(e)) {
555                visited.add(e);
556                try {
557                    for (RelationMember em : e.getMembers()) {
558                        em.getMember().accept(this);
559                    }
560                } finally {
561                    visited.remove(e);
562                }
563            }
564        }
565    }
566
567    /**
568     * Warns the user about the number of detected conflicts
569     *
570     * @param numNewConflicts the number of detected conflicts
571     * @since 5775
572     */
573    public void warnNumNewConflicts(int numNewConflicts) {
574        if (numNewConflicts == 0)
575            return;
576
577        String msg1 = trn(
578                "There was {0} conflict detected.",
579                "There were {0} conflicts detected.",
580                numNewConflicts,
581                numNewConflicts
582        );
583
584        final StringBuilder sb = new StringBuilder();
585        sb.append("<html>").append(msg1).append("</html>");
586        if (numNewConflicts > 0) {
587            final ButtonSpec[] options = new ButtonSpec[] {
588                    new ButtonSpec(
589                            tr("OK"),
590                            ImageProvider.get("ok"),
591                            tr("Click to close this dialog and continue editing"),
592                            null /* no specific help */
593                    )
594            };
595            GuiHelper.runInEDT(() -> {
596                HelpAwareOptionPane.showOptionDialog(
597                        Main.parent,
598                        sb.toString(),
599                        tr("Conflicts detected"),
600                        JOptionPane.WARNING_MESSAGE,
601                        null, /* no icon */
602                        options,
603                        options[0],
604                        ht("/Concepts/Conflict#WarningAboutDetectedConflicts")
605                );
606                unfurlDialog();
607                Main.map.repaint();
608            });
609        }
610    }
611}