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