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