001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.layer.geoimage; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.awt.BorderLayout; 007import java.awt.Component; 008import java.awt.Dimension; 009import java.awt.GridBagConstraints; 010import java.awt.GridBagLayout; 011import java.awt.event.ActionEvent; 012import java.awt.event.KeyEvent; 013import java.awt.event.WindowEvent; 014import java.text.DateFormat; 015import java.text.SimpleDateFormat; 016 017import javax.swing.Box; 018import javax.swing.JButton; 019import javax.swing.JPanel; 020import javax.swing.JToggleButton; 021 022import org.openstreetmap.josm.actions.JosmAction; 023import org.openstreetmap.josm.gui.MainApplication; 024import org.openstreetmap.josm.gui.dialogs.DialogsPanel.Action; 025import org.openstreetmap.josm.gui.dialogs.ToggleDialog; 026import org.openstreetmap.josm.gui.layer.Layer; 027import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent; 028import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener; 029import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent; 030import org.openstreetmap.josm.gui.layer.LayerManager.LayerRemoveEvent; 031import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeEvent; 032import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeListener; 033import org.openstreetmap.josm.tools.ImageProvider; 034import org.openstreetmap.josm.tools.Shortcut; 035import org.openstreetmap.josm.tools.date.DateUtils; 036 037/** 038 * Dialog to view and manipulate geo-tagged images from a {@link GeoImageLayer}. 039 */ 040public final class ImageViewerDialog extends ToggleDialog implements LayerChangeListener, ActiveLayerChangeListener { 041 042 private final ImageZoomAction imageZoomAction = new ImageZoomAction(); 043 private final ImageCenterViewAction imageCenterViewAction = new ImageCenterViewAction(); 044 private final ImageNextAction imageNextAction = new ImageNextAction(); 045 private final ImageRemoveAction imageRemoveAction = new ImageRemoveAction(); 046 private final ImageRemoveFromDiskAction imageRemoveFromDiskAction = new ImageRemoveFromDiskAction(); 047 private final ImagePreviousAction imagePreviousAction = new ImagePreviousAction(); 048 private final ImageCollapseAction imageCollapseAction = new ImageCollapseAction(); 049 private final ImageFirstAction imageFirstAction = new ImageFirstAction(); 050 private final ImageLastAction imageLastAction = new ImageLastAction(); 051 private final ImageCopyPathAction imageCopyPathAction = new ImageCopyPathAction(); 052 053 private final ImageDisplay imgDisplay = new ImageDisplay(); 054 private boolean centerView; 055 056 // Only one instance of that class is present at one time 057 private static volatile ImageViewerDialog dialog; 058 059 private boolean collapseButtonClicked; 060 061 static void createInstance() { 062 if (dialog != null) 063 throw new IllegalStateException("ImageViewerDialog instance was already created"); 064 dialog = new ImageViewerDialog(); 065 } 066 067 /** 068 * Replies the unique instance of this dialog 069 * @return the unique instance 070 */ 071 public static ImageViewerDialog getInstance() { 072 if (dialog == null) 073 throw new AssertionError("a new instance needs to be created first"); 074 return dialog; 075 } 076 077 private JButton btnNext; 078 private JButton btnPrevious; 079 private JButton btnCollapse; 080 private JToggleButton tbCentre; 081 082 private ImageViewerDialog() { 083 super(tr("Geotagged Images"), "geoimage", tr("Display geotagged images"), Shortcut.registerShortcut("tools:geotagged", 084 tr("Tool: {0}", tr("Display geotagged images")), KeyEvent.VK_Y, Shortcut.DIRECT), 200); 085 build(); 086 MainApplication.getLayerManager().addActiveLayerChangeListener(this); 087 MainApplication.getLayerManager().addLayerChangeListener(this); 088 } 089 090 private void build() { 091 JPanel content = new JPanel(new BorderLayout()); 092 093 content.add(imgDisplay, BorderLayout.CENTER); 094 095 Dimension buttonDim = new Dimension(26, 26); 096 097 btnPrevious = new JButton(imagePreviousAction); 098 btnPrevious.setPreferredSize(buttonDim); 099 btnPrevious.setEnabled(false); 100 101 JButton btnDelete = new JButton(imageRemoveAction); 102 btnDelete.setPreferredSize(buttonDim); 103 104 JButton btnDeleteFromDisk = new JButton(imageRemoveFromDiskAction); 105 btnDeleteFromDisk.setPreferredSize(buttonDim); 106 107 JButton btnCopyPath = new JButton(imageCopyPathAction); 108 btnCopyPath.setPreferredSize(buttonDim); 109 110 btnNext = new JButton(imageNextAction); 111 btnNext.setPreferredSize(buttonDim); 112 btnNext.setEnabled(false); 113 114 tbCentre = new JToggleButton(imageCenterViewAction); 115 tbCentre.setPreferredSize(buttonDim); 116 117 JButton btnZoomBestFit = new JButton(imageZoomAction); 118 btnZoomBestFit.setPreferredSize(buttonDim); 119 120 btnCollapse = new JButton(imageCollapseAction); 121 btnCollapse.setPreferredSize(new Dimension(20, 20)); 122 btnCollapse.setAlignmentY(Component.TOP_ALIGNMENT); 123 124 JPanel buttons = new JPanel(); 125 buttons.add(btnPrevious); 126 buttons.add(btnNext); 127 buttons.add(Box.createRigidArea(new Dimension(7, 0))); 128 buttons.add(tbCentre); 129 buttons.add(btnZoomBestFit); 130 buttons.add(Box.createRigidArea(new Dimension(7, 0))); 131 buttons.add(btnDelete); 132 buttons.add(btnDeleteFromDisk); 133 buttons.add(Box.createRigidArea(new Dimension(7, 0))); 134 buttons.add(btnCopyPath); 135 136 JPanel bottomPane = new JPanel(new GridBagLayout()); 137 GridBagConstraints gc = new GridBagConstraints(); 138 gc.gridx = 0; 139 gc.gridy = 0; 140 gc.anchor = GridBagConstraints.CENTER; 141 gc.weightx = 1; 142 bottomPane.add(buttons, gc); 143 144 gc.gridx = 1; 145 gc.gridy = 0; 146 gc.anchor = GridBagConstraints.PAGE_END; 147 gc.weightx = 0; 148 bottomPane.add(btnCollapse, gc); 149 150 content.add(bottomPane, BorderLayout.SOUTH); 151 152 createLayout(content, false, null); 153 } 154 155 @Override 156 public void destroy() { 157 MainApplication.getLayerManager().removeActiveLayerChangeListener(this); 158 MainApplication.getLayerManager().removeLayerChangeListener(this); 159 // Manually destroy actions until JButtons are replaced by standard SideButtons 160 imageFirstAction.destroy(); 161 imageLastAction.destroy(); 162 imagePreviousAction.destroy(); 163 imageNextAction.destroy(); 164 imageCenterViewAction.destroy(); 165 imageCollapseAction.destroy(); 166 imageCopyPathAction.destroy(); 167 imageRemoveAction.destroy(); 168 imageRemoveFromDiskAction.destroy(); 169 imageZoomAction.destroy(); 170 super.destroy(); 171 dialog = null; 172 } 173 174 private class ImageNextAction extends JosmAction { 175 ImageNextAction() { 176 super(null, new ImageProvider("dialogs", "next"), tr("Next"), Shortcut.registerShortcut( 177 "geoimage:next", tr("Geoimage: {0}", tr("Show next Image")), KeyEvent.VK_PAGE_DOWN, Shortcut.DIRECT), 178 false, null, false); 179 } 180 181 @Override 182 public void actionPerformed(ActionEvent e) { 183 if (currentLayer != null) { 184 currentLayer.showNextPhoto(); 185 } 186 } 187 } 188 189 private class ImagePreviousAction extends JosmAction { 190 ImagePreviousAction() { 191 super(null, new ImageProvider("dialogs", "previous"), tr("Previous"), Shortcut.registerShortcut( 192 "geoimage:previous", tr("Geoimage: {0}", tr("Show previous Image")), KeyEvent.VK_PAGE_UP, Shortcut.DIRECT), 193 false, null, false); 194 } 195 196 @Override 197 public void actionPerformed(ActionEvent e) { 198 if (currentLayer != null) { 199 currentLayer.showPreviousPhoto(); 200 } 201 } 202 } 203 204 private class ImageFirstAction extends JosmAction { 205 ImageFirstAction() { 206 super(null, (ImageProvider) null, null, Shortcut.registerShortcut( 207 "geoimage:first", tr("Geoimage: {0}", tr("Show first Image")), KeyEvent.VK_HOME, Shortcut.DIRECT), 208 false, null, false); 209 } 210 211 @Override 212 public void actionPerformed(ActionEvent e) { 213 if (currentLayer != null) { 214 currentLayer.showFirstPhoto(); 215 } 216 } 217 } 218 219 private class ImageLastAction extends JosmAction { 220 ImageLastAction() { 221 super(null, (ImageProvider) null, null, Shortcut.registerShortcut( 222 "geoimage:last", tr("Geoimage: {0}", tr("Show last Image")), KeyEvent.VK_END, Shortcut.DIRECT), 223 false, null, false); 224 } 225 226 @Override 227 public void actionPerformed(ActionEvent e) { 228 if (currentLayer != null) { 229 currentLayer.showLastPhoto(); 230 } 231 } 232 } 233 234 private class ImageCenterViewAction extends JosmAction { 235 ImageCenterViewAction() { 236 super(null, new ImageProvider("dialogs", "centreview"), tr("Center view"), null, 237 false, null, false); 238 } 239 240 @Override 241 public void actionPerformed(ActionEvent e) { 242 final JToggleButton button = (JToggleButton) e.getSource(); 243 centerView = button.isEnabled() && button.isSelected(); 244 if (centerView && currentEntry != null && currentEntry.getPos() != null) { 245 MainApplication.getMap().mapView.zoomTo(currentEntry.getPos()); 246 } 247 } 248 } 249 250 private class ImageZoomAction extends JosmAction { 251 ImageZoomAction() { 252 super(null, new ImageProvider("dialogs", "zoom-best-fit"), tr("Zoom best fit and 1:1"), null, 253 false, null, false); 254 } 255 256 @Override 257 public void actionPerformed(ActionEvent e) { 258 imgDisplay.zoomBestFitOrOne(); 259 } 260 } 261 262 private class ImageRemoveAction extends JosmAction { 263 ImageRemoveAction() { 264 super(null, new ImageProvider("dialogs", "delete"), tr("Remove photo from layer"), Shortcut.registerShortcut( 265 "geoimage:deleteimagefromlayer", tr("Geoimage: {0}", tr("Remove photo from layer")), KeyEvent.VK_DELETE, Shortcut.SHIFT), 266 false, null, false); 267 } 268 269 @Override 270 public void actionPerformed(ActionEvent e) { 271 if (currentLayer != null) { 272 currentLayer.removeCurrentPhoto(); 273 } 274 } 275 } 276 277 private class ImageRemoveFromDiskAction extends JosmAction { 278 ImageRemoveFromDiskAction() { 279 super(null, new ImageProvider("dialogs", "geoimage/deletefromdisk"), tr("Delete image file from disk"), 280 Shortcut.registerShortcut( 281 "geoimage:deletefilefromdisk", tr("Geoimage: {0}", tr("Delete File from disk")), KeyEvent.VK_DELETE, Shortcut.CTRL_SHIFT), 282 false, null, false); 283 } 284 285 @Override 286 public void actionPerformed(ActionEvent e) { 287 if (currentLayer != null) { 288 currentLayer.removeCurrentPhotoFromDisk(); 289 } 290 } 291 } 292 293 private class ImageCopyPathAction extends JosmAction { 294 ImageCopyPathAction() { 295 super(null, new ImageProvider("copy"), tr("Copy image path"), Shortcut.registerShortcut( 296 "geoimage:copypath", tr("Geoimage: {0}", tr("Copy image path")), KeyEvent.VK_C, Shortcut.ALT_CTRL_SHIFT), 297 false, null, false); 298 } 299 300 @Override 301 public void actionPerformed(ActionEvent e) { 302 if (currentLayer != null) { 303 currentLayer.copyCurrentPhotoPath(); 304 } 305 } 306 } 307 308 private class ImageCollapseAction extends JosmAction { 309 ImageCollapseAction() { 310 super(null, new ImageProvider("dialogs", "collapse"), tr("Move dialog to the side pane"), null, 311 false, null, false); 312 } 313 314 @Override 315 public void actionPerformed(ActionEvent e) { 316 collapseButtonClicked = true; 317 detachedDialog.getToolkit().getSystemEventQueue().postEvent(new WindowEvent(detachedDialog, WindowEvent.WINDOW_CLOSING)); 318 } 319 } 320 321 public static void showImage(GeoImageLayer layer, ImageEntry entry) { 322 getInstance().displayImage(layer, entry); 323 if (layer != null) { 324 layer.checkPreviousNextButtons(); 325 } else { 326 setPreviousEnabled(false); 327 setNextEnabled(false); 328 } 329 } 330 331 /** 332 * Enables (or disables) the "Previous" button. 333 * @param value {@code true} to enable the button, {@code false} otherwise 334 */ 335 public static void setPreviousEnabled(boolean value) { 336 getInstance().btnPrevious.setEnabled(value); 337 } 338 339 /** 340 * Enables (or disables) the "Next" button. 341 * @param value {@code true} to enable the button, {@code false} otherwise 342 */ 343 public static void setNextEnabled(boolean value) { 344 getInstance().btnNext.setEnabled(value); 345 } 346 347 /** 348 * Enables (or disables) the "Center view" button. 349 * @param value {@code true} to enable the button, {@code false} otherwise 350 * @return the old enabled value. Can be used to restore the original enable state 351 */ 352 public static synchronized boolean setCentreEnabled(boolean value) { 353 final ImageViewerDialog instance = getInstance(); 354 final boolean wasEnabled = instance.tbCentre.isEnabled(); 355 instance.tbCentre.setEnabled(value); 356 instance.tbCentre.getAction().actionPerformed(new ActionEvent(instance.tbCentre, 0, null)); 357 return wasEnabled; 358 } 359 360 private transient GeoImageLayer currentLayer; 361 private transient ImageEntry currentEntry; 362 363 public void displayImage(GeoImageLayer layer, ImageEntry entry) { 364 boolean imageChanged; 365 366 synchronized (this) { 367 // TODO: pop up image dialog but don't load image again 368 369 imageChanged = currentEntry != entry; 370 371 if (centerView && entry != null && MainApplication.isDisplayingMapView() && entry.getPos() != null) { 372 MainApplication.getMap().mapView.zoomTo(entry.getPos()); 373 } 374 375 currentLayer = layer; 376 currentEntry = entry; 377 } 378 379 if (entry != null) { 380 if (imageChanged) { 381 // Set only if the image is new to preserve zoom and position if the same image is redisplayed 382 // (e.g. to update the OSD). 383 imgDisplay.setImage(entry); 384 } 385 setTitle(tr("Geotagged Images") + (entry.getFile() != null ? " - " + entry.getFile().getName() : "")); 386 StringBuilder osd = new StringBuilder(entry.getFile() != null ? entry.getFile().getName() : ""); 387 if (entry.getElevation() != null) { 388 osd.append(tr("\nAltitude: {0} m", Math.round(entry.getElevation()))); 389 } 390 if (entry.getSpeed() != null) { 391 osd.append(tr("\nSpeed: {0} km/h", Math.round(entry.getSpeed()))); 392 } 393 if (entry.getExifImgDir() != null) { 394 osd.append(tr("\nDirection {0}\u00b0", Math.round(entry.getExifImgDir()))); 395 } 396 DateFormat dtf = DateUtils.getDateTimeFormat(DateFormat.SHORT, DateFormat.MEDIUM); 397 // Make sure date/time format includes milliseconds 398 if (dtf instanceof SimpleDateFormat) { 399 String pattern = ((SimpleDateFormat) dtf).toPattern(); 400 if (!pattern.contains(".SSS")) { 401 dtf = new SimpleDateFormat(pattern.replace(":ss", ":ss.SSS")); 402 } 403 } 404 if (entry.hasExifTime()) { 405 osd.append(tr("\nEXIF time: {0}", dtf.format(entry.getExifTime()))); 406 } 407 if (entry.hasGpsTime()) { 408 osd.append(tr("\nGPS time: {0}", dtf.format(entry.getGpsTime()))); 409 } 410 411 imgDisplay.setOsdText(osd.toString()); 412 } else { 413 // if this method is called to reinitialize dialog content with a blank image, 414 // do not actually show the dialog again with a blank image if currently hidden (fix #10672) 415 setTitle(tr("Geotagged Images")); 416 imgDisplay.setImage(null); 417 imgDisplay.setOsdText(""); 418 return; 419 } 420 if (!isDialogShowing()) { 421 setIsDocked(false); // always open a detached window when an image is clicked and dialog is closed 422 showDialog(); 423 } else { 424 if (isDocked && isCollapsed) { 425 expand(); 426 dialogsPanel.reconstruct(Action.COLLAPSED_TO_DEFAULT, this); 427 } 428 } 429 } 430 431 /** 432 * When an image is closed, really close it and do not pop 433 * up the side dialog. 434 */ 435 @Override 436 protected boolean dockWhenClosingDetachedDlg() { 437 if (collapseButtonClicked) { 438 collapseButtonClicked = false; 439 return true; 440 } 441 return false; 442 } 443 444 @Override 445 protected void stateChanged() { 446 super.stateChanged(); 447 if (btnCollapse != null) { 448 btnCollapse.setVisible(!isDocked); 449 } 450 } 451 452 /** 453 * Returns whether an image is currently displayed 454 * @return If image is currently displayed 455 */ 456 public boolean hasImage() { 457 return currentEntry != null; 458 } 459 460 /** 461 * Returns the currently displayed image. 462 * @return Currently displayed image or {@code null} 463 * @since 6392 464 */ 465 public static ImageEntry getCurrentImage() { 466 return getInstance().currentEntry; 467 } 468 469 /** 470 * Returns the layer associated with the image. 471 * @return Layer associated with the image 472 * @since 6392 473 */ 474 public static GeoImageLayer getCurrentLayer() { 475 return getInstance().currentLayer; 476 } 477 478 /** 479 * Returns whether the center view is currently active. 480 * @return {@code true} if the center view is active, {@code false} otherwise 481 * @since 9416 482 */ 483 public static boolean isCenterView() { 484 return getInstance().centerView; 485 } 486 487 @Override 488 public void layerAdded(LayerAddEvent e) { 489 showLayer(e.getAddedLayer()); 490 } 491 492 @Override 493 public void layerRemoving(LayerRemoveEvent e) { 494 // Clear current image and layer if current layer is deleted 495 if (currentLayer != null && currentLayer.equals(e.getRemovedLayer())) { 496 showImage(null, null); 497 } 498 // Check buttons state in case of layer merging 499 if (currentLayer != null && e.getRemovedLayer() instanceof GeoImageLayer) { 500 currentLayer.checkPreviousNextButtons(); 501 } 502 } 503 504 @Override 505 public void layerOrderChanged(LayerOrderChangeEvent e) { 506 // ignored 507 } 508 509 @Override 510 public void activeOrEditLayerChanged(ActiveLayerChangeEvent e) { 511 showLayer(e.getSource().getActiveLayer()); 512 } 513 514 private void showLayer(Layer newLayer) { 515 if (currentLayer == null && newLayer instanceof GeoImageLayer) { 516 ((GeoImageLayer) newLayer).showFirstPhoto(); 517 } 518 } 519}