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.Arrays;
016import java.util.Collection;
017import java.util.HashSet;
018import java.util.LinkedList;
019import java.util.Set;
020import java.util.concurrent.CopyOnWriteArrayList;
021
022import javax.swing.AbstractAction;
023import javax.swing.JList;
024import javax.swing.JOptionPane;
025import javax.swing.JPopupMenu;
026import javax.swing.ListModel;
027import javax.swing.ListSelectionModel;
028import javax.swing.event.ListDataEvent;
029import javax.swing.event.ListDataListener;
030import javax.swing.event.ListSelectionEvent;
031import javax.swing.event.ListSelectionListener;
032
033import org.openstreetmap.josm.Main;
034import org.openstreetmap.josm.actions.AbstractSelectAction;
035import org.openstreetmap.josm.data.SelectionChangedListener;
036import org.openstreetmap.josm.data.conflict.Conflict;
037import org.openstreetmap.josm.data.conflict.ConflictCollection;
038import org.openstreetmap.josm.data.conflict.IConflictListener;
039import org.openstreetmap.josm.data.osm.DataSet;
040import org.openstreetmap.josm.data.osm.Node;
041import org.openstreetmap.josm.data.osm.OsmPrimitive;
042import org.openstreetmap.josm.data.osm.Relation;
043import org.openstreetmap.josm.data.osm.RelationMember;
044import org.openstreetmap.josm.data.osm.Way;
045import org.openstreetmap.josm.data.osm.visitor.AbstractVisitor;
046import org.openstreetmap.josm.data.osm.visitor.Visitor;
047import org.openstreetmap.josm.gui.HelpAwareOptionPane;
048import org.openstreetmap.josm.gui.HelpAwareOptionPane.ButtonSpec;
049import org.openstreetmap.josm.gui.MapView;
050import org.openstreetmap.josm.gui.NavigatableComponent;
051import org.openstreetmap.josm.gui.OsmPrimitivRenderer;
052import org.openstreetmap.josm.gui.PopupMenuHandler;
053import org.openstreetmap.josm.gui.SideButton;
054import org.openstreetmap.josm.gui.layer.OsmDataLayer;
055import org.openstreetmap.josm.gui.util.GuiHelper;
056import org.openstreetmap.josm.gui.widgets.PopupMenuLauncher;
057import org.openstreetmap.josm.tools.ImageProvider;
058import org.openstreetmap.josm.tools.Shortcut;
059
060/**
061 * This dialog displays the {@link ConflictCollection} of the active {@link OsmDataLayer} in a toggle
062 * dialog on the right of the main frame.
063 *
064 */
065public final class ConflictDialog extends ToggleDialog implements MapView.EditLayerChangeListener, IConflictListener, SelectionChangedListener {
066
067    /**
068     * Replies the color used to paint conflicts.
069     *
070     * @return the color used to paint conflicts
071     * @see #paintConflicts
072     * @since 1221
073     */
074    public static Color getColor() {
075        return Main.pref.getColor(marktr("conflict"), Color.gray);
076    }
077
078    /** the collection of conflicts displayed by this conflict dialog */
079    private transient ConflictCollection conflicts;
080
081    /** the model for the list of conflicts */
082    private transient ConflictListModel model;
083    /** the list widget for the list of conflicts */
084    private JList<OsmPrimitive> lstConflicts;
085
086    private final JPopupMenu popupMenu = new JPopupMenu();
087    private final transient PopupMenuHandler popupMenuHandler = new PopupMenuHandler(popupMenu);
088
089    private ResolveAction actResolve;
090    private SelectAction actSelect;
091
092    /**
093     * builds the GUI
094     */
095    protected void build() {
096        model = new ConflictListModel();
097
098        lstConflicts = new JList<>(model);
099        lstConflicts.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
100        lstConflicts.setCellRenderer(new OsmPrimitivRenderer());
101        lstConflicts.addMouseListener(new MouseEventHandler());
102        addListSelectionListener(new ListSelectionListener() {
103            @Override
104            public void valueChanged(ListSelectionEvent e) {
105                Main.map.mapView.repaint();
106            }
107        });
108
109        SideButton btnResolve = new SideButton(actResolve = new ResolveAction());
110        addListSelectionListener(actResolve);
111
112        SideButton btnSelect = new SideButton(actSelect = new SelectAction());
113        addListSelectionListener(actSelect);
114
115        createLayout(lstConflicts, true, Arrays.asList(new SideButton[] {
116            btnResolve, btnSelect
117        }));
118
119        popupMenuHandler.addAction(Main.main.menu.autoScaleActions.get("conflict"));
120    }
121
122    /**
123     * constructor
124     */
125    public ConflictDialog() {
126        super(tr("Conflict"), "conflict", tr("Resolve conflicts."),
127                Shortcut.registerShortcut("subwindow:conflict", tr("Toggle: {0}", tr("Conflict")),
128                KeyEvent.VK_C, Shortcut.ALT_SHIFT), 100);
129
130        build();
131        refreshView();
132    }
133
134    @Override
135    public void showNotify() {
136        DataSet.addSelectionListener(this);
137        MapView.addEditLayerChangeListener(this, true);
138        refreshView();
139    }
140
141    @Override
142    public void hideNotify() {
143        MapView.removeEditLayerChangeListener(this);
144        DataSet.removeSelectionListener(this);
145    }
146
147    /**
148     * Add a list selection listener to the conflicts list.
149     * @param listener the ListSelectionListener
150     * @since 5958
151     */
152    public void addListSelectionListener(ListSelectionListener listener) {
153        lstConflicts.getSelectionModel().addListSelectionListener(listener);
154    }
155
156    /**
157     * Remove the given list selection listener from the conflicts list.
158     * @param listener the ListSelectionListener
159     * @since 5958
160     */
161    public void removeListSelectionListener(ListSelectionListener listener) {
162        lstConflicts.getSelectionModel().removeListSelectionListener(listener);
163    }
164
165    /**
166     * Replies the popup menu handler.
167     * @return The popup menu handler
168     * @since 5958
169     */
170    public PopupMenuHandler getPopupMenuHandler() {
171        return popupMenuHandler;
172    }
173
174    /**
175     * Launches a conflict resolution dialog for the first selected conflict
176     *
177     */
178    private void resolve() {
179        if (conflicts == null || model.getSize() == 0) return;
180
181        int index = lstConflicts.getSelectedIndex();
182        if (index < 0) {
183            index = 0;
184        }
185
186        Conflict<? extends OsmPrimitive> c = conflicts.get(index);
187        ConflictResolutionDialog dialog = new ConflictResolutionDialog(Main.parent);
188        dialog.getConflictResolver().populate(c);
189        dialog.setVisible(true);
190
191        lstConflicts.setSelectedIndex(index);
192
193        Main.map.mapView.repaint();
194    }
195
196    /**
197     * refreshes the view of this dialog
198     */
199    public void refreshView() {
200        OsmDataLayer editLayer =  Main.main.getEditLayer();
201        conflicts = (editLayer == null ? new ConflictCollection() : editLayer.getConflicts());
202        GuiHelper.runInEDT(new Runnable() {
203            @Override
204            public void run() {
205                model.fireContentChanged();
206                updateTitle();
207            }
208        });
209    }
210
211    private void updateTitle() {
212        int conflictsCount = conflicts.size();
213        if (conflictsCount > 0) {
214            setTitle(trn("Conflict: {0} unresolved", "Conflicts: {0} unresolved", conflictsCount, conflictsCount) +
215                    " ("+tr("Rel.:{0} / Ways:{1} / Nodes:{2}",
216                            conflicts.getRelationConflicts().size(),
217                            conflicts.getWayConflicts().size(),
218                            conflicts.getNodeConflicts().size())+')');
219        } else {
220            setTitle(tr("Conflict"));
221        }
222    }
223
224    /**
225     * Paints all conflicts that can be expressed on the main window.
226     *
227     * @param g The {@code Graphics} used to paint
228     * @param nc The {@code NavigatableComponent} used to get screen coordinates of nodes
229     * @since 86
230     */
231    public void paintConflicts(final Graphics g, final NavigatableComponent nc) {
232        Color preferencesColor = getColor();
233        if (preferencesColor.equals(Main.pref.getColor(marktr("background"), Color.black)))
234            return;
235        g.setColor(preferencesColor);
236        Visitor conflictPainter = new AbstractVisitor() {
237            // Manage a stack of visited relations to avoid infinite recursion with cyclic relations (fix #7938)
238            private final Set<Relation> visited = new HashSet<>();
239            @Override
240            public void visit(Node n) {
241                Point p = nc.getPoint(n);
242                g.drawRect(p.x-1, p.y-1, 2, 2);
243            }
244
245            public void visit(Node n1, Node n2) {
246                Point p1 = nc.getPoint(n1);
247                Point p2 = nc.getPoint(n2);
248                g.drawLine(p1.x, p1.y, p2.x, p2.y);
249            }
250
251            @Override
252            public void visit(Way w) {
253                Node lastN = null;
254                for (Node n : w.getNodes()) {
255                    if (lastN == null) {
256                        lastN = n;
257                        continue;
258                    }
259                    visit(lastN, n);
260                    lastN = n;
261                }
262            }
263
264            @Override
265            public void visit(Relation e) {
266                if (!visited.contains(e)) {
267                    visited.add(e);
268                    try {
269                        for (RelationMember em : e.getMembers()) {
270                            em.getMember().accept(this);
271                        }
272                    } finally {
273                        visited.remove(e);
274                    }
275                }
276            }
277        };
278        for (OsmPrimitive o : lstConflicts.getSelectedValuesList()) {
279            if (conflicts == null || !conflicts.hasConflictForMy(o)) {
280                continue;
281            }
282            conflicts.getConflictForMy(o).getTheir().accept(conflictPainter);
283        }
284    }
285
286    @Override
287    public void editLayerChanged(OsmDataLayer oldLayer, OsmDataLayer newLayer) {
288        if (oldLayer != null) {
289            oldLayer.getConflicts().removeConflictListener(this);
290        }
291        if (newLayer != null) {
292            newLayer.getConflicts().addConflictListener(this);
293        }
294        refreshView();
295    }
296
297
298    /**
299     * replies the conflict collection currently held by this dialog; may be null
300     *
301     * @return the conflict collection currently held by this dialog; may be null
302     */
303    public ConflictCollection getConflicts() {
304        return conflicts;
305    }
306
307    /**
308     * returns the first selected item of the conflicts list
309     *
310     * @return Conflict
311     */
312    public Conflict<? extends OsmPrimitive> getSelectedConflict() {
313        if (conflicts == null || model.getSize() == 0) return null;
314
315        int index = lstConflicts.getSelectedIndex();
316        if (index < 0) return null;
317
318        return conflicts.get(index);
319    }
320
321    @Override
322    public void onConflictsAdded(ConflictCollection conflicts) {
323        refreshView();
324    }
325
326    @Override
327    public void onConflictsRemoved(ConflictCollection conflicts) {
328        Main.info("1 conflict has been resolved.");
329        refreshView();
330    }
331
332    @Override
333    public void selectionChanged(Collection<? extends OsmPrimitive> newSelection) {
334        lstConflicts.clearSelection();
335        for (OsmPrimitive osm : newSelection) {
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    }
344
345    @Override
346    public String helpTopic() {
347        return ht("/Dialog/ConflictList");
348    }
349
350    class MouseEventHandler extends PopupMenuLauncher {
351        /**
352         * Constructs a new {@code MouseEventHandler}.
353         */
354        MouseEventHandler() {
355            super(popupMenu);
356        }
357
358        @Override public void mouseClicked(MouseEvent e) {
359            if (isDoubleClick(e)) {
360                resolve();
361            }
362        }
363    }
364
365    /**
366     * The {@link ListModel} for conflicts
367     *
368     */
369    class ConflictListModel implements ListModel<OsmPrimitive> {
370
371        private final CopyOnWriteArrayList<ListDataListener> listeners;
372
373        /**
374         * Constructs a new {@code ConflictListModel}.
375         */
376        ConflictListModel() {
377            listeners = new CopyOnWriteArrayList<>();
378        }
379
380        @Override
381        public void addListDataListener(ListDataListener l) {
382            if (l != null) {
383                listeners.addIfAbsent(l);
384            }
385        }
386
387        @Override
388        public void removeListDataListener(ListDataListener l) {
389            listeners.remove(l);
390        }
391
392        protected void fireContentChanged() {
393            ListDataEvent evt = new ListDataEvent(
394                    this,
395                    ListDataEvent.CONTENTS_CHANGED,
396                    0,
397                    getSize()
398            );
399            for (ListDataListener listener : listeners) {
400                listener.contentsChanged(evt);
401            }
402        }
403
404        @Override
405        public OsmPrimitive getElementAt(int index) {
406            if (index < 0) return null;
407            if (index >= getSize()) return null;
408            return conflicts.get(index).getMy();
409        }
410
411        @Override
412        public int getSize() {
413            if (conflicts == null) return 0;
414            return conflicts.size();
415        }
416
417        public int indexOf(OsmPrimitive my) {
418            if (conflicts == null) return -1;
419            for (int i = 0; i < conflicts.size(); i++) {
420                if (conflicts.get(i).isMatchingMy(my))
421                    return i;
422            }
423            return -1;
424        }
425
426        public OsmPrimitive get(int idx) {
427            if (conflicts == null) return null;
428            return conflicts.get(idx).getMy();
429        }
430    }
431
432    class ResolveAction extends AbstractAction implements ListSelectionListener {
433        ResolveAction() {
434            putValue(NAME, tr("Resolve"));
435            putValue(SHORT_DESCRIPTION,  tr("Open a merge dialog of all selected items in the list above."));
436            putValue(SMALL_ICON, ImageProvider.get("dialogs", "conflict"));
437            putValue("help", ht("/Dialog/ConflictList#ResolveAction"));
438        }
439
440        @Override
441        public void actionPerformed(ActionEvent e) {
442            resolve();
443        }
444
445        @Override
446        public void valueChanged(ListSelectionEvent e) {
447            ListSelectionModel model = (ListSelectionModel) e.getSource();
448            boolean enabled = model.getMinSelectionIndex() >= 0
449            && model.getMaxSelectionIndex() >= model.getMinSelectionIndex();
450            setEnabled(enabled);
451        }
452    }
453
454    final class SelectAction extends AbstractSelectAction implements ListSelectionListener {
455        private SelectAction() {
456            putValue("help", ht("/Dialog/ConflictList#SelectAction"));
457        }
458
459        @Override
460        public void actionPerformed(ActionEvent e) {
461            Collection<OsmPrimitive> sel = new LinkedList<>();
462            for (OsmPrimitive o : lstConflicts.getSelectedValuesList()) {
463                sel.add(o);
464            }
465            DataSet ds = Main.main.getCurrentDataSet();
466            if (ds != null) { // Can't see how it is possible but it happened in #7942
467                ds.setSelected(sel);
468            }
469        }
470
471        @Override
472        public void valueChanged(ListSelectionEvent e) {
473            ListSelectionModel model = (ListSelectionModel) e.getSource();
474            boolean enabled = model.getMinSelectionIndex() >= 0
475            && model.getMaxSelectionIndex() >= model.getMinSelectionIndex();
476            setEnabled(enabled);
477        }
478    }
479
480    /**
481     * Warns the user about the number of detected conflicts
482     *
483     * @param numNewConflicts the number of detected conflicts
484     * @since 5775
485     */
486    public void warnNumNewConflicts(int numNewConflicts) {
487        if (numNewConflicts == 0) return;
488
489        String msg1 = trn(
490                "There was {0} conflict detected.",
491                "There were {0} conflicts detected.",
492                numNewConflicts,
493                numNewConflicts
494        );
495
496        final StringBuilder sb = new StringBuilder();
497        sb.append("<html>").append(msg1).append("</html>");
498        if (numNewConflicts > 0) {
499            final ButtonSpec[] options = new ButtonSpec[] {
500                    new ButtonSpec(
501                            tr("OK"),
502                            ImageProvider.get("ok"),
503                            tr("Click to close this dialog and continue editing"),
504                            null /* no specific help */
505                    )
506            };
507            GuiHelper.runInEDT(new Runnable() {
508                @Override
509                public void run() {
510                    HelpAwareOptionPane.showOptionDialog(
511                            Main.parent,
512                            sb.toString(),
513                            tr("Conflicts detected"),
514                            JOptionPane.WARNING_MESSAGE,
515                            null, /* no icon */
516                            options,
517                            options[0],
518                            ht("/Concepts/Conflict#WarningAboutDetectedConflicts")
519                    );
520                    unfurlDialog();
521                    Main.map.repaint();
522                }
523            });
524        }
525    }
526}