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}