001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.history;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005import static org.openstreetmap.josm.tools.I18n.trn;
006
007import java.awt.Component;
008import java.awt.Dimension;
009import java.awt.Point;
010import java.util.ArrayList;
011import java.util.Collection;
012import java.util.Iterator;
013import java.util.LinkedHashMap;
014import java.util.LinkedList;
015import java.util.List;
016import java.util.Map.Entry;
017import java.util.Objects;
018
019import javax.swing.JOptionPane;
020import javax.swing.SwingUtilities;
021
022import org.openstreetmap.josm.data.osm.PrimitiveId;
023import org.openstreetmap.josm.data.osm.history.History;
024import org.openstreetmap.josm.data.osm.history.HistoryDataSet;
025import org.openstreetmap.josm.gui.MainApplication;
026import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent;
027import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener;
028import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent;
029import org.openstreetmap.josm.gui.layer.LayerManager.LayerRemoveEvent;
030import org.openstreetmap.josm.gui.util.WindowGeometry;
031import org.openstreetmap.josm.spi.preferences.Config;
032import org.openstreetmap.josm.tools.JosmRuntimeException;
033import org.openstreetmap.josm.tools.Logging;
034import org.openstreetmap.josm.tools.SubclassFilteredCollection;
035import org.openstreetmap.josm.tools.bugreport.BugReportExceptionHandler;
036
037/**
038 * Manager allowing to show/hide history dialogs.
039 * @since 2019
040 */
041public final class HistoryBrowserDialogManager implements LayerChangeListener {
042
043    private static boolean isUnloaded(PrimitiveId p) {
044        History h = HistoryDataSet.getInstance().getHistory(p);
045        if (h == null)
046            // reload if the history is not in the cache yet
047            return true;
048        else
049            // reload if the history object of the selected object is not in the cache yet
050            return !p.isNew() && h.getByVersion(p.getUniqueId()) == null;
051    }
052
053    private static final String WINDOW_GEOMETRY_PREF = HistoryBrowserDialogManager.class.getName() + ".geometry";
054
055    private static HistoryBrowserDialogManager instance;
056
057    private final LinkedHashMap<Long, HistoryBrowserDialog> dialogs = new LinkedHashMap<>();
058
059    private static final List<HistoryHook> hooks = new ArrayList<>();
060
061    private HistoryBrowserDialogManager() {
062        MainApplication.getLayerManager().addLayerChangeListener(this);
063    }
064
065    /**
066     * Replies the unique instance.
067     * @return the unique instance
068     */
069    public static synchronized HistoryBrowserDialogManager getInstance() {
070        if (instance == null) {
071            instance = new HistoryBrowserDialogManager();
072        }
073        return instance;
074    }
075
076    /**
077     * Determines if an history dialog exists for the given object id.
078     * @param id the object id
079     * @return {@code true} if an history dialog exists for the given object id, {@code false} otherwise
080     */
081    public boolean existsDialog(long id) {
082        return dialogs.containsKey(id);
083    }
084
085    private boolean hasDialogWithCloseUpperLeftCorner(Point p) {
086        return dialogs.values().stream()
087                .map(Component::getLocation)
088                .anyMatch(corner -> p.x >= corner.x - 5 && corner.x + 5 >= p.x && p.y >= corner.y - 5 && corner.y + 5 >= p.y);
089    }
090
091    private void placeOnScreen(HistoryBrowserDialog dialog) {
092        WindowGeometry geometry = new WindowGeometry(WINDOW_GEOMETRY_PREF, WindowGeometry.centerOnScreen(new Dimension(850, 500)));
093        geometry.applySafe(dialog);
094        Point p = dialog.getLocation();
095        while (hasDialogWithCloseUpperLeftCorner(p)) {
096            p.x += 20;
097            p.y += 20;
098        }
099        dialog.setLocation(p);
100    }
101
102    /**
103     * Hides the specified history dialog and cleans associated resources.
104     * @param dialog History dialog to hide
105     */
106    public void hide(HistoryBrowserDialog dialog) {
107        for (Iterator<Entry<Long, HistoryBrowserDialog>> it = dialogs.entrySet().iterator(); it.hasNext();) {
108            if (Objects.equals(it.next().getValue(), dialog)) {
109                it.remove();
110                if (dialogs.isEmpty()) {
111                    new WindowGeometry(dialog).remember(WINDOW_GEOMETRY_PREF);
112                }
113                break;
114            }
115        }
116        dialog.setVisible(false);
117        dialog.dispose();
118
119        if (!dialogs.isEmpty()) {
120            // see #17270: set focus to last dialog
121            new LinkedList<>(dialogs.values()).getLast().toFront();
122        } else {
123            // we always reload the history, so there is no need to keep it in the cache.
124            HistoryDataSet.getInstance().clear();
125        }
126    }
127
128    /**
129     * Hides and destroys all currently visible history browser dialogs
130     * @since 2448
131     */
132    public void hideAll() {
133        new ArrayList<>(dialogs.values()).forEach(this::hide);
134    }
135
136    /**
137     * Show history dialog for the given history.
138     * @param h History to show
139     * @since 2448
140     */
141    public void show(History h) {
142        if (h == null)
143            return;
144        if (existsDialog(h.getId())) {
145            dialogs.get(h.getId()).toFront();
146        } else {
147            HistoryBrowserDialog dialog = new HistoryBrowserDialog(h);
148            placeOnScreen(dialog);
149            dialog.setVisible(true);
150            dialogs.put(h.getId(), dialog);
151        }
152    }
153
154    /* ----------------------------------------------------------------------------- */
155    /* LayerChangeListener                                                           */
156    /* ----------------------------------------------------------------------------- */
157    @Override
158    public void layerAdded(LayerAddEvent e) {
159        // Do nothing
160    }
161
162    @Override
163    public void layerRemoving(LayerRemoveEvent e) {
164        // remove all history browsers if the number of layers drops to 0
165        if (e.getSource().getLayers().isEmpty()) {
166            hideAll();
167        }
168    }
169
170    @Override
171    public void layerOrderChanged(LayerOrderChangeEvent e) {
172        // Do nothing
173    }
174
175    /**
176     * Adds a new {@code HistoryHook}.
177     * @param hook hook to add
178     * @return {@code true} (as specified by {@link Collection#add})
179     * @since 13947
180     */
181    public static boolean addHistoryHook(HistoryHook hook) {
182        return hooks.add(Objects.requireNonNull(hook));
183    }
184
185    /**
186     * Removes an existing {@code HistoryHook}.
187     * @param hook hook to remove
188     * @return {@code true} if this list contained the specified element
189     * @since 13947
190     */
191    public static boolean removeHistoryHook(HistoryHook hook) {
192        return hooks.remove(Objects.requireNonNull(hook));
193    }
194
195    /**
196     * Show history dialog(s) for the given primitive(s).
197     * @param primitives The primitive(s) for which history will be displayed
198     */
199    public void showHistory(final Collection<? extends PrimitiveId> primitives) {
200        showHistory(MainApplication.getMainFrame(), primitives);
201    }
202
203    /**
204     * Show history dialog(s) for the given primitive(s).
205     * @param parent Parent component for displayed dialog boxes
206     * @param primitives The primitive(s) for which history will be displayed
207     * @since 16123
208     */
209    public void showHistory(Component parent, final Collection<? extends PrimitiveId> primitives) {
210        final List<PrimitiveId> realPrimitives = new ArrayList<>(primitives);
211        hooks.forEach(h -> h.modifyRequestedIds(realPrimitives));
212        final Collection<? extends PrimitiveId> notNewPrimitives = SubclassFilteredCollection.filter(realPrimitives, p1 -> !p1.isNew());
213        if (notNewPrimitives.isEmpty()) {
214            JOptionPane.showMessageDialog(
215                    parent,
216                    tr("Please select at least one already uploaded node, way, or relation."),
217                    tr("Warning"),
218                    JOptionPane.WARNING_MESSAGE);
219            return;
220        }
221        if (notNewPrimitives.size() > Config.getPref().getInt("warn.open.maxhistory", 5) &&
222                /* I18N english text for value 1 makes no real sense, never called for values <= maxhistory (usually 5) */
223                JOptionPane.OK_OPTION != JOptionPane.showConfirmDialog(MainApplication.getMainFrame(),
224                        "<html>" + trn(
225                                "You are about to open <b>{0}</b> history dialog.<br/>Do you want to continue?",
226                                "You are about to open <b>{0}</b> different history dialogs simultaneously.<br/>Do you want to continue?",
227                                notNewPrimitives.size(), notNewPrimitives.size()) + "</html>",
228                        tr("Confirmation"), JOptionPane.OK_CANCEL_OPTION, JOptionPane.WARNING_MESSAGE)) {
229            return;
230        }
231
232        Collection<? extends PrimitiveId> toLoad = SubclassFilteredCollection.filter(notNewPrimitives, HistoryBrowserDialogManager::isUnloaded);
233        if (!toLoad.isEmpty()) {
234            MainApplication.worker.submit(new HistoryLoadTask(parent).addPrimitiveIds(toLoad));
235        }
236
237        Runnable r = () -> {
238            try {
239                for (PrimitiveId p : notNewPrimitives) {
240                    final History h = HistoryDataSet.getInstance().getHistory(p);
241                    if (h == null) {
242                        Logging.warn("{0} not found in HistoryDataSet", p);
243                        continue;
244                    }
245                    SwingUtilities.invokeLater(() -> show(h));
246                }
247            } catch (final JosmRuntimeException | IllegalArgumentException | IllegalStateException e) {
248                BugReportExceptionHandler.handleException(e);
249            }
250        };
251        MainApplication.worker.submit(r);
252    }
253}