001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.layer.geoimage; 003 004import static org.openstreetmap.josm.tools.I18n.marktr; 005import static org.openstreetmap.josm.tools.I18n.tr; 006import static org.openstreetmap.josm.tools.I18n.trn; 007 008import java.awt.BorderLayout; 009import java.awt.Component; 010import java.awt.Dimension; 011import java.awt.GridBagConstraints; 012import java.awt.GridBagLayout; 013import java.awt.event.ActionEvent; 014import java.awt.event.KeyEvent; 015import java.awt.event.WindowEvent; 016import java.io.IOException; 017import java.io.Serializable; 018import java.time.ZoneOffset; 019import java.time.format.DateTimeFormatter; 020import java.time.format.FormatStyle; 021import java.util.ArrayList; 022import java.util.Arrays; 023import java.util.Collections; 024import java.util.List; 025import java.util.Objects; 026import java.util.Optional; 027import java.util.concurrent.Future; 028import java.util.function.UnaryOperator; 029import java.util.stream.Collectors; 030 031import javax.swing.AbstractAction; 032import javax.swing.Box; 033import javax.swing.JButton; 034import javax.swing.JLabel; 035import javax.swing.JOptionPane; 036import javax.swing.JPanel; 037import javax.swing.JToggleButton; 038import javax.swing.SwingConstants; 039 040import org.openstreetmap.josm.actions.JosmAction; 041import org.openstreetmap.josm.data.ImageData; 042import org.openstreetmap.josm.data.ImageData.ImageDataUpdateListener; 043import org.openstreetmap.josm.data.imagery.street_level.IImageEntry; 044import org.openstreetmap.josm.gui.ExtendedDialog; 045import org.openstreetmap.josm.gui.MainApplication; 046import org.openstreetmap.josm.gui.datatransfer.ClipboardUtils; 047import org.openstreetmap.josm.gui.dialogs.DialogsPanel; 048import org.openstreetmap.josm.gui.dialogs.ToggleDialog; 049import org.openstreetmap.josm.gui.dialogs.layer.LayerVisibilityAction; 050import org.openstreetmap.josm.gui.layer.Layer; 051import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent; 052import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener; 053import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent; 054import org.openstreetmap.josm.gui.layer.LayerManager.LayerRemoveEvent; 055import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeEvent; 056import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeListener; 057import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings; 058import org.openstreetmap.josm.gui.util.imagery.Vector3D; 059import org.openstreetmap.josm.tools.ImageProvider; 060import org.openstreetmap.josm.tools.Logging; 061import org.openstreetmap.josm.tools.PlatformManager; 062import org.openstreetmap.josm.tools.Shortcut; 063import org.openstreetmap.josm.tools.date.DateUtils; 064 065/** 066 * Dialog to view and manipulate geo-tagged images from a {@link GeoImageLayer}. 067 */ 068public final class ImageViewerDialog extends ToggleDialog implements LayerChangeListener, ActiveLayerChangeListener, ImageDataUpdateListener { 069 private static final String GEOIMAGE_FILLER = marktr("Geoimage: {0}"); 070 private static final String DIALOG_FOLDER = "dialogs"; 071 072 private final ImageryFilterSettings imageryFilterSettings = new ImageryFilterSettings(); 073 074 private final ImageZoomAction imageZoomAction = new ImageZoomAction(); 075 private final ImageCenterViewAction imageCenterViewAction = new ImageCenterViewAction(); 076 private final ImageNextAction imageNextAction = new ImageNextAction(); 077 private final ImageRemoveAction imageRemoveAction = new ImageRemoveAction(); 078 private final ImageRemoveFromDiskAction imageRemoveFromDiskAction = new ImageRemoveFromDiskAction(); 079 private final ImagePreviousAction imagePreviousAction = new ImagePreviousAction(); 080 private final ImageCollapseAction imageCollapseAction = new ImageCollapseAction(); 081 private final ImageFirstAction imageFirstAction = new ImageFirstAction(); 082 private final ImageLastAction imageLastAction = new ImageLastAction(); 083 private final ImageCopyPathAction imageCopyPathAction = new ImageCopyPathAction(); 084 private final ImageOpenExternalAction imageOpenExternalAction = new ImageOpenExternalAction(); 085 private final LayerVisibilityAction visibilityAction = new LayerVisibilityAction(Collections::emptyList, 086 () -> Collections.singleton(imageryFilterSettings)); 087 088 private final ImageDisplay imgDisplay = new ImageDisplay(imageryFilterSettings); 089 private Future<?> imgLoadingFuture; 090 private boolean centerView; 091 092 // Only one instance of that class is present at one time 093 private static volatile ImageViewerDialog dialog; 094 095 private boolean collapseButtonClicked; 096 097 static void createInstance() { 098 if (dialog != null) 099 throw new IllegalStateException("ImageViewerDialog instance was already created"); 100 dialog = new ImageViewerDialog(); 101 } 102 103 /** 104 * Replies the unique instance of this dialog 105 * @return the unique instance 106 */ 107 public static ImageViewerDialog getInstance() { 108 if (dialog == null) 109 throw new AssertionError("a new instance needs to be created first"); 110 return dialog; 111 } 112 113 private JButton btnLast; 114 private JButton btnNext; 115 private JButton btnPrevious; 116 private JButton btnFirst; 117 private JButton btnCollapse; 118 private JButton btnDelete; 119 private JButton btnCopyPath; 120 private JButton btnOpenExternal; 121 private JButton btnDeleteFromDisk; 122 private JToggleButton tbCentre; 123 124 private ImageViewerDialog() { 125 super(tr("Geotagged Images"), "geoimage", tr("Display geotagged images"), Shortcut.registerShortcut("tools:geotagged", 126 tr("Windows: {0}", tr("Geotagged Images")), KeyEvent.VK_Y, Shortcut.DIRECT), 200); 127 build(); 128 MainApplication.getLayerManager().addActiveLayerChangeListener(this); 129 MainApplication.getLayerManager().addLayerChangeListener(this); 130 for (Layer l: MainApplication.getLayerManager().getLayers()) { 131 registerOnLayer(l); 132 } 133 } 134 135 private static JButton createButton(AbstractAction action, Dimension buttonDim) { 136 JButton btn = new JButton(action); 137 btn.setPreferredSize(buttonDim); 138 btn.addPropertyChangeListener("enabled", e -> action.setEnabled(Boolean.TRUE.equals(e.getNewValue()))); 139 return btn; 140 } 141 142 private static JButton createNavigationButton(AbstractAction action, Dimension buttonDim) { 143 JButton btn = createButton(action, buttonDim); 144 btn.setEnabled(false); 145 action.addPropertyChangeListener(l -> { 146 if ("enabled".equals(l.getPropertyName())) { 147 btn.setEnabled(action.isEnabled()); 148 } 149 }); 150 return btn; 151 } 152 153 private void build() { 154 JPanel content = new JPanel(new BorderLayout()); 155 156 content.add(imgDisplay, BorderLayout.CENTER); 157 158 Dimension buttonDim = new Dimension(26, 26); 159 160 btnFirst = createNavigationButton(imageFirstAction, buttonDim); 161 btnPrevious = createNavigationButton(imagePreviousAction, buttonDim); 162 163 btnDelete = createButton(imageRemoveAction, buttonDim); 164 btnDeleteFromDisk = createButton(imageRemoveFromDiskAction, buttonDim); 165 btnCopyPath = createButton(imageCopyPathAction, buttonDim); 166 btnOpenExternal = createButton(imageOpenExternalAction, buttonDim); 167 168 btnNext = createNavigationButton(imageNextAction, buttonDim); 169 btnLast = createNavigationButton(imageLastAction, buttonDim); 170 171 tbCentre = new JToggleButton(imageCenterViewAction); 172 tbCentre.setPreferredSize(buttonDim); 173 174 JButton btnZoomBestFit = new JButton(imageZoomAction); 175 btnZoomBestFit.setPreferredSize(buttonDim); 176 177 btnCollapse = createButton(imageCollapseAction, new Dimension(20, 20)); 178 btnCollapse.setAlignmentY(Component.TOP_ALIGNMENT); 179 180 JPanel buttons = new JPanel(); 181 buttons.add(btnFirst); 182 buttons.add(btnPrevious); 183 buttons.add(btnNext); 184 buttons.add(btnLast); 185 buttons.add(Box.createRigidArea(new Dimension(7, 0))); 186 buttons.add(tbCentre); 187 buttons.add(btnZoomBestFit); 188 buttons.add(Box.createRigidArea(new Dimension(7, 0))); 189 buttons.add(btnDelete); 190 buttons.add(btnDeleteFromDisk); 191 buttons.add(Box.createRigidArea(new Dimension(7, 0))); 192 buttons.add(btnCopyPath); 193 buttons.add(btnOpenExternal); 194 buttons.add(Box.createRigidArea(new Dimension(7, 0))); 195 buttons.add(createButton(visibilityAction, buttonDim)); 196 197 JPanel bottomPane = new JPanel(new GridBagLayout()); 198 GridBagConstraints gc = new GridBagConstraints(); 199 gc.gridx = 0; 200 gc.gridy = 0; 201 gc.anchor = GridBagConstraints.CENTER; 202 gc.weightx = 1; 203 bottomPane.add(buttons, gc); 204 205 gc.gridx = 1; 206 gc.gridy = 0; 207 gc.anchor = GridBagConstraints.PAGE_END; 208 gc.weightx = 0; 209 bottomPane.add(btnCollapse, gc); 210 211 content.add(bottomPane, BorderLayout.SOUTH); 212 213 createLayout(content, false, null); 214 } 215 216 @Override 217 public void destroy() { 218 MainApplication.getLayerManager().removeActiveLayerChangeListener(this); 219 MainApplication.getLayerManager().removeLayerChangeListener(this); 220 // Manually destroy actions until JButtons are replaced by standard SideButtons 221 imageFirstAction.destroy(); 222 imageLastAction.destroy(); 223 imagePreviousAction.destroy(); 224 imageNextAction.destroy(); 225 imageCenterViewAction.destroy(); 226 imageCollapseAction.destroy(); 227 imageCopyPathAction.destroy(); 228 imageOpenExternalAction.destroy(); 229 imageRemoveAction.destroy(); 230 imageRemoveFromDiskAction.destroy(); 231 imageZoomAction.destroy(); 232 cancelLoadingImage(); 233 super.destroy(); 234 dialog = null; 235 } 236 237 /** 238 * This literally exists to silence sonarlint complaints. 239 * @param <I> the type of the operand and result of the operator 240 */ 241 @FunctionalInterface 242 private interface SerializableUnaryOperator<I> extends UnaryOperator<I>, Serializable { 243 } 244 245 private abstract class ImageAction extends JosmAction { 246 final SerializableUnaryOperator<IImageEntry<?>> supplier; 247 ImageAction(String name, ImageProvider icon, String tooltip, Shortcut shortcut, 248 boolean registerInToolbar, String toolbarId, boolean installAdaptors, 249 final SerializableUnaryOperator<IImageEntry<?>> supplier) { 250 super(name, icon, tooltip, shortcut, registerInToolbar, toolbarId, installAdaptors); 251 Objects.requireNonNull(supplier); 252 this.supplier = supplier; 253 } 254 255 @Override 256 public void actionPerformed(ActionEvent event) { 257 final IImageEntry<?> entry = ImageViewerDialog.this.currentEntry; 258 if (entry != null) { 259 IImageEntry<?> nextEntry = this.getSupplier().apply(entry); 260 entry.selectImage(ImageViewerDialog.this, nextEntry); 261 } 262 this.resetRememberActions(); 263 } 264 265 void resetRememberActions() { 266 for (ImageRememberAction action : Arrays.asList(ImageViewerDialog.this.imageLastAction, ImageViewerDialog.this.imageFirstAction)) { 267 action.last = null; 268 action.updateEnabledState(); 269 } 270 } 271 272 SerializableUnaryOperator<IImageEntry<?>> getSupplier() { 273 return this.supplier; 274 } 275 276 @Override 277 protected void updateEnabledState() { 278 final IImageEntry<?> entry = ImageViewerDialog.this.currentEntry; 279 this.setEnabled(entry != null && this.getSupplier().apply(entry) != null); 280 } 281 } 282 283 private class ImageNextAction extends ImageAction { 284 ImageNextAction() { 285 super(null, new ImageProvider(DIALOG_FOLDER, "next"), tr("Next"), Shortcut.registerShortcut( 286 "geoimage:next", tr(GEOIMAGE_FILLER, tr("Show next Image")), KeyEvent.VK_PAGE_DOWN, Shortcut.DIRECT), 287 false, null, false, IImageEntry::getNextImage); 288 } 289 } 290 291 private class ImagePreviousAction extends ImageAction { 292 ImagePreviousAction() { 293 super(null, new ImageProvider(DIALOG_FOLDER, "previous"), tr("Previous"), Shortcut.registerShortcut( 294 "geoimage:previous", tr(GEOIMAGE_FILLER, tr("Show previous Image")), KeyEvent.VK_PAGE_UP, Shortcut.DIRECT), 295 false, null, false, IImageEntry::getPreviousImage); 296 } 297 } 298 299 /** This class exists to remember the last entry, and go back if clicked again when it would not otherwise be enabled */ 300 private abstract class ImageRememberAction extends ImageAction { 301 private final ImageProvider defaultIcon; 302 transient IImageEntry<?> last; 303 ImageRememberAction(String name, ImageProvider icon, String tooltip, Shortcut shortcut, 304 boolean registerInToolbar, String toolbarId, boolean installAdaptors, SerializableUnaryOperator<IImageEntry<?>> supplier) { 305 super(name, icon, tooltip, shortcut, registerInToolbar, toolbarId, installAdaptors, supplier); 306 this.defaultIcon = icon; 307 } 308 309 public void updateIcon() { 310 if (this.last != null) { 311 new ImageProvider(DIALOG_FOLDER, "history").getResource().attachImageIcon(this, true); 312 } else { 313 this.defaultIcon.getResource().attachImageIcon(this, true); 314 } 315 } 316 317 @Override 318 public void actionPerformed(ActionEvent event) { 319 final IImageEntry<?> current = ImageViewerDialog.this.currentEntry; 320 final IImageEntry<?> expected = this.supplier.apply(current); 321 if (current != null) { 322 IImageEntry<?> nextEntry = this.getSupplier().apply(current); 323 current.selectImage(ImageViewerDialog.this, nextEntry); 324 } 325 this.resetRememberActions(); 326 if (!Objects.equals(current, expected)) { 327 this.last = current; 328 } else { 329 this.last = null; 330 } 331 this.updateEnabledState(); 332 } 333 334 @Override 335 protected void updateEnabledState() { 336 final IImageEntry<?> current = ImageViewerDialog.this.currentEntry; 337 final IImageEntry<?> nextEntry = current != null ? this.getSupplier().apply(current) : null; 338 if (this.last == null && nextEntry != null && nextEntry.equals(current)) { 339 this.setEnabled(false); 340 } else { 341 super.updateEnabledState(); 342 } 343 this.updateIcon(); 344 } 345 346 @Override 347 SerializableUnaryOperator<IImageEntry<?>> getSupplier() { 348 if (this.last != null) { 349 return entry -> this.last; 350 } 351 return super.getSupplier(); 352 } 353 } 354 355 private class ImageFirstAction extends ImageRememberAction { 356 ImageFirstAction() { 357 super(null, new ImageProvider(DIALOG_FOLDER, "first"), tr("First"), Shortcut.registerShortcut( 358 "geoimage:first", tr(GEOIMAGE_FILLER, tr("Show first Image")), KeyEvent.VK_HOME, Shortcut.DIRECT), 359 false, null, false, IImageEntry::getFirstImage); 360 } 361 } 362 363 private class ImageLastAction extends ImageRememberAction { 364 ImageLastAction() { 365 super(null, new ImageProvider(DIALOG_FOLDER, "last"), tr("Last"), Shortcut.registerShortcut( 366 "geoimage:last", tr(GEOIMAGE_FILLER, tr("Show last Image")), KeyEvent.VK_END, Shortcut.DIRECT), 367 false, null, false, IImageEntry::getLastImage); 368 } 369 } 370 371 private class ImageCenterViewAction extends JosmAction { 372 ImageCenterViewAction() { 373 super(null, new ImageProvider("dialogs/autoscale", "selection"), tr("Center view"), null, 374 false, null, false); 375 } 376 377 @Override 378 public void actionPerformed(ActionEvent e) { 379 final JToggleButton button = (JToggleButton) e.getSource(); 380 centerView = button.isEnabled() && button.isSelected(); 381 if (centerView && currentEntry != null && currentEntry.getPos() != null) { 382 MainApplication.getMap().mapView.zoomTo(currentEntry.getPos()); 383 } 384 } 385 } 386 387 private class ImageZoomAction extends JosmAction { 388 ImageZoomAction() { 389 super(null, new ImageProvider(DIALOG_FOLDER, "zoom-best-fit"), tr("Zoom best fit and 1:1"), null, 390 false, null, false); 391 } 392 393 @Override 394 public void actionPerformed(ActionEvent e) { 395 imgDisplay.zoomBestFitOrOne(); 396 } 397 } 398 399 private class ImageRemoveAction extends JosmAction { 400 ImageRemoveAction() { 401 super(null, new ImageProvider(DIALOG_FOLDER, "delete"), tr("Remove photo from layer"), Shortcut.registerShortcut( 402 "geoimage:deleteimagefromlayer", tr(GEOIMAGE_FILLER, tr("Remove photo from layer")), KeyEvent.VK_DELETE, Shortcut.SHIFT), 403 false, null, false); 404 } 405 406 @Override 407 public void actionPerformed(ActionEvent e) { 408 if (ImageViewerDialog.this.currentEntry != null) { 409 IImageEntry<?> imageEntry = ImageViewerDialog.this.currentEntry; 410 if (imageEntry.isRemoveSupported()) { 411 imageEntry.remove(); 412 } 413 } 414 } 415 } 416 417 private class ImageRemoveFromDiskAction extends JosmAction { 418 ImageRemoveFromDiskAction() { 419 super(null, new ImageProvider(DIALOG_FOLDER, "geoimage/deletefromdisk"), tr("Delete image file from disk"), 420 Shortcut.registerShortcut("geoimage:deletefilefromdisk", 421 tr(GEOIMAGE_FILLER, tr("Delete image file from disk")), KeyEvent.VK_DELETE, Shortcut.CTRL_SHIFT), 422 false, null, false); 423 } 424 425 @Override 426 public void actionPerformed(ActionEvent e) { 427 if (currentEntry != null) { 428 List<IImageEntry<?>> toDelete = currentEntry instanceof ImageEntry ? 429 new ArrayList<>(((ImageEntry) currentEntry).getDataSet().getSelectedImages()) 430 : Collections.singletonList(currentEntry); 431 int size = toDelete.size(); 432 433 int result = new ExtendedDialog( 434 MainApplication.getMainFrame(), 435 tr("Delete image file from disk"), 436 tr("Cancel"), tr("Delete")) 437 .setButtonIcons("cancel", "dialogs/geoimage/deletefromdisk") 438 .setContent(new JLabel("<html><h3>" 439 + trn("Delete the file from disk?", 440 "Delete the {0} files from disk?", size, size) 441 + "<p>" + trn("The image file will be permanently lost!", 442 "The images files will be permanently lost!", size) + "</h3></html>", 443 ImageProvider.get("dialogs/geoimage/deletefromdisk"), SwingConstants.LEADING)) 444 .toggleEnable("geoimage.deleteimagefromdisk") 445 .setCancelButton(1) 446 .setDefaultButton(2) 447 .showDialog() 448 .getValue(); 449 450 if (result == 2) { 451 final List<ImageData> imageDataCollection = toDelete.stream().filter(ImageEntry.class::isInstance) 452 .map(ImageEntry.class::cast).map(ImageEntry::getDataSet).distinct().collect(Collectors.toList()); 453 for (IImageEntry<?> delete : toDelete) { 454 // We have to be able to remove the image from the layer and the image from its storage location 455 // If either are false, then don't remove the image. 456 if (delete.isRemoveSupported() && delete.isDeleteSupported() && delete.remove() && delete.delete()) { 457 Logging.info("File {0} deleted.", delete.getFile()); 458 } else { 459 JOptionPane.showMessageDialog( 460 MainApplication.getMainFrame(), 461 tr("Image file could not be deleted."), 462 tr("Error"), 463 JOptionPane.ERROR_MESSAGE 464 ); 465 } 466 } 467 imageDataCollection.forEach(data -> { 468 data.notifyImageUpdate(); 469 data.updateSelectedImage(); 470 }); 471 } 472 } 473 } 474 } 475 476 private class ImageCopyPathAction extends JosmAction { 477 ImageCopyPathAction() { 478 super(null, new ImageProvider("copy"), tr("Copy image path"), Shortcut.registerShortcut( 479 "geoimage:copypath", tr(GEOIMAGE_FILLER, tr("Copy image path")), KeyEvent.VK_C, Shortcut.ALT_CTRL_SHIFT), 480 false, null, false); 481 } 482 483 @Override 484 public void actionPerformed(ActionEvent e) { 485 if (currentEntry != null) { 486 ClipboardUtils.copyString(String.valueOf(currentEntry.getFile())); 487 } 488 } 489 } 490 491 private class ImageCollapseAction extends JosmAction { 492 ImageCollapseAction() { 493 super(null, new ImageProvider(DIALOG_FOLDER, "collapse"), tr("Move dialog to the side pane"), null, 494 false, null, false); 495 } 496 497 @Override 498 public void actionPerformed(ActionEvent e) { 499 collapseButtonClicked = true; 500 detachedDialog.getToolkit().getSystemEventQueue().postEvent(new WindowEvent(detachedDialog, WindowEvent.WINDOW_CLOSING)); 501 } 502 } 503 504 private class ImageOpenExternalAction extends JosmAction { 505 ImageOpenExternalAction() { 506 super(null, new ImageProvider("external-link"), tr("Open image in external viewer"), null, false, null, false); 507 } 508 509 @Override 510 public void actionPerformed(ActionEvent e) { 511 if (currentEntry != null) { 512 try { 513 PlatformManager.getPlatform().openUrl(currentEntry.getFile().toURI().toURL().toExternalForm()); 514 } catch (IOException ex) { 515 Logging.error(ex); 516 } 517 } 518 } 519 } 520 521 /** 522 * Enables (or disables) the "Previous" button. 523 * @param value {@code true} to enable the button, {@code false} otherwise 524 */ 525 public void setPreviousEnabled(boolean value) { 526 this.imageFirstAction.updateEnabledState(); 527 this.btnFirst.setEnabled(value || this.imageFirstAction.isEnabled()); 528 btnPrevious.setEnabled(value); 529 } 530 531 /** 532 * Enables (or disables) the "Next" button. 533 * @param value {@code true} to enable the button, {@code false} otherwise 534 */ 535 public void setNextEnabled(boolean value) { 536 btnNext.setEnabled(value); 537 this.imageLastAction.updateEnabledState(); 538 this.btnLast.setEnabled(value || this.imageLastAction.isEnabled()); 539 } 540 541 /** 542 * Enables (or disables) the "Center view" button. 543 * @param value {@code true} to enable the button, {@code false} otherwise 544 * @return the old enabled value. Can be used to restore the original enable state 545 */ 546 public static synchronized boolean setCentreEnabled(boolean value) { 547 final ImageViewerDialog instance = getInstance(); 548 final boolean wasEnabled = instance.tbCentre.isEnabled(); 549 instance.tbCentre.setEnabled(value); 550 instance.tbCentre.getAction().actionPerformed(new ActionEvent(instance.tbCentre, 0, null)); 551 return wasEnabled; 552 } 553 554 private transient IImageEntry<? extends IImageEntry<?>> currentEntry; 555 556 /** 557 * Displays a single image for the given layer. 558 * @param ignoredData the image data 559 * @param entry image entry 560 * @see #displayImages 561 */ 562 public void displayImage(ImageData ignoredData, ImageEntry entry) { 563 displayImages(Collections.singletonList(entry)); 564 } 565 566 /** 567 * Displays a single image for the given layer. 568 * @param entry image entry 569 * @see #displayImages 570 */ 571 public void displayImage(IImageEntry<?> entry) { 572 this.displayImages(Collections.singletonList(entry)); 573 } 574 575 /** 576 * Displays images for the given layer. 577 * @param entries image entries 578 * @since 18246 579 */ 580 public void displayImages(List<IImageEntry<?>> entries) { 581 boolean imageChanged; 582 IImageEntry<?> entry = entries != null && entries.size() == 1 ? entries.get(0) : null; 583 584 synchronized (this) { 585 // TODO: pop up image dialog but don't load image again 586 587 imageChanged = currentEntry != entry; 588 589 if (centerView && entry != null && MainApplication.isDisplayingMapView() && entry.getPos() != null) { 590 MainApplication.getMap().mapView.zoomTo(entry.getPos()); 591 } 592 593 currentEntry = entry; 594 595 for (ImageAction action : Arrays.asList(this.imageFirstAction, this.imagePreviousAction, 596 this.imageNextAction, this.imageLastAction)) { 597 action.updateEnabledState(); 598 } 599 } 600 601 if (entry != null) { 602 this.updateButtonsNonNullEntry(entry, imageChanged); 603 } else { 604 this.updateButtonsNullEntry(entries); 605 return; 606 } 607 if (!isDialogShowing()) { 608 setIsDocked(false); // always open a detached window when an image is clicked and dialog is closed 609 showDialog(); 610 } else if (isDocked && isCollapsed) { 611 expand(); 612 dialogsPanel.reconstruct(DialogsPanel.Action.COLLAPSED_TO_DEFAULT, this); 613 } 614 } 615 616 /** 617 * Update buttons for null entry 618 * @param entries {@code true} if multiple images are selected 619 */ 620 private void updateButtonsNullEntry(List<IImageEntry<?>> entries) { 621 boolean hasMultipleImages = entries != null && entries.size() > 1; 622 // if this method is called to reinitialize dialog content with a blank image, 623 // do not actually show the dialog again with a blank image if currently hidden (fix #10672) 624 setTitle(tr("Geotagged Images")); 625 imgDisplay.setImage(null); 626 imgDisplay.setOsdText(""); 627 setNextEnabled(false); 628 setPreviousEnabled(false); 629 btnDelete.setEnabled(hasMultipleImages); 630 btnDeleteFromDisk.setEnabled(hasMultipleImages); 631 btnCopyPath.setEnabled(false); 632 btnOpenExternal.setEnabled(false); 633 if (hasMultipleImages) { 634 imgDisplay.setEmptyText(tr("Multiple images selected")); 635 btnFirst.setEnabled(!isFirstImageSelected(entries)); 636 btnLast.setEnabled(!isLastImageSelected(entries)); 637 } 638 imgDisplay.setImage(null); 639 imgDisplay.setOsdText(""); 640 } 641 642 /** 643 * Update the image viewer buttons for the new entry 644 * @param entry The new entry 645 * @param imageChanged {@code true} if it is not the same image as the previous image. 646 */ 647 private void updateButtonsNonNullEntry(IImageEntry<?> entry, boolean imageChanged) { 648 if (imageChanged) { 649 cancelLoadingImage(); 650 // Set only if the image is new to preserve zoom and position if the same image is redisplayed 651 // (e.g. to update the OSD). 652 imgLoadingFuture = imgDisplay.setImage(entry); 653 } 654 655 // Update buttons after setting the new entry 656 setNextEnabled(entry.getNextImage() != null); 657 setPreviousEnabled(entry.getPreviousImage() != null); 658 btnDelete.setEnabled(entry.isRemoveSupported()); 659 btnDeleteFromDisk.setEnabled(entry.isDeleteSupported() && entry.isRemoveSupported()); 660 btnCopyPath.setEnabled(true); 661 btnOpenExternal.setEnabled(true); 662 663 setTitle(tr("Geotagged Images") + (!entry.getDisplayName().isEmpty() ? " - " + entry.getDisplayName() : "")); 664 StringBuilder osd = new StringBuilder(entry.getDisplayName()); 665 if (entry.getElevation() != null) { 666 osd.append(tr("\nAltitude: {0} m", Math.round(entry.getElevation()))); 667 } 668 if (entry.getSpeed() != null) { 669 osd.append(tr("\nSpeed: {0} km/h", Math.round(entry.getSpeed()))); 670 } 671 if (entry.getExifImgDir() != null) { 672 osd.append(tr("\nDirection {0}\u00b0", Math.round(entry.getExifImgDir()))); 673 } 674 675 DateTimeFormatter dtf = DateUtils.getDateTimeFormatter(FormatStyle.SHORT, FormatStyle.MEDIUM) 676 // Set timezone to UTC since UTC is assumed when parsing the EXIF timestamp, 677 // see see org.openstreetmap.josm.tools.ExifReader.readTime(com.drew.metadata.Metadata) 678 .withZone(ZoneOffset.UTC); 679 680 if (entry.hasExifTime()) { 681 osd.append(tr("\nEXIF time: {0}", dtf.format(entry.getExifInstant()))); 682 } 683 if (entry.hasGpsTime()) { 684 osd.append(tr("\nGPS time: {0}", dtf.format(entry.getGpsInstant()))); 685 } 686 Optional.ofNullable(entry.getIptcCaption()).map(s -> tr("\nCaption: {0}", s)).ifPresent(osd::append); 687 Optional.ofNullable(entry.getIptcHeadline()).map(s -> tr("\nHeadline: {0}", s)).ifPresent(osd::append); 688 Optional.ofNullable(entry.getIptcKeywords()).map(s -> tr("\nKeywords: {0}", s)).ifPresent(osd::append); 689 Optional.ofNullable(entry.getIptcObjectName()).map(s -> tr("\nObject name: {0}", s)).ifPresent(osd::append); 690 691 imgDisplay.setOsdText(osd.toString()); 692 } 693 694 /** 695 * Displays images for the given layer. 696 * @param ignoredData the image data (unused, may be {@code null}) 697 * @param entries image entries 698 * @since 18246 (signature) 699 * @deprecated Use {@link #displayImages(List)} (The data param is no longer used) 700 */ 701 @Deprecated 702 public void displayImages(ImageData ignoredData, List<IImageEntry<?>> entries) { 703 this.displayImages(entries); 704 } 705 706 private static boolean isLastImageSelected(List<IImageEntry<?>> data) { 707 return data.stream().anyMatch(image -> data.contains(image.getLastImage())); 708 } 709 710 private static boolean isFirstImageSelected(List<IImageEntry<?>> data) { 711 return data.stream().anyMatch(image -> data.contains(image.getFirstImage())); 712 } 713 714 /** 715 * When an image is closed, really close it and do not pop 716 * up the side dialog. 717 */ 718 @Override 719 protected boolean dockWhenClosingDetachedDlg() { 720 if (collapseButtonClicked) { 721 collapseButtonClicked = false; 722 return super.dockWhenClosingDetachedDlg(); 723 } 724 return false; 725 } 726 727 @Override 728 protected void stateChanged() { 729 super.stateChanged(); 730 if (btnCollapse != null) { 731 btnCollapse.setVisible(!isDocked); 732 } 733 } 734 735 /** 736 * Returns whether an image is currently displayed 737 * @return If image is currently displayed 738 */ 739 public boolean hasImage() { 740 return currentEntry != null; 741 } 742 743 /** 744 * Returns the currently displayed image. 745 * @return Currently displayed image or {@code null} 746 * @since 18246 (signature) 747 */ 748 public static IImageEntry<?> getCurrentImage() { 749 return getInstance().currentEntry; 750 } 751 752 /** 753 * Returns the rotation of the currently displayed image. 754 * @param entry The entry to get the rotation for. May be {@code null}. 755 * @return the rotation of the currently displayed image, or {@code null} 756 * @since 18263 757 */ 758 public Vector3D getRotation(IImageEntry<?> entry) { 759 return imgDisplay.getRotation(entry); 760 } 761 762 /** 763 * Returns whether the center view is currently active. 764 * @return {@code true} if the center view is active, {@code false} otherwise 765 * @since 9416 766 */ 767 public static boolean isCenterView() { 768 return getInstance().centerView; 769 } 770 771 @Override 772 public void layerAdded(LayerAddEvent e) { 773 registerOnLayer(e.getAddedLayer()); 774 showLayer(e.getAddedLayer()); 775 } 776 777 @Override 778 public void layerRemoving(LayerRemoveEvent e) { 779 if (e.getRemovedLayer() instanceof GeoImageLayer && this.currentEntry instanceof ImageEntry) { 780 ImageData removedData = ((GeoImageLayer) e.getRemovedLayer()).getImageData(); 781 if (removedData == ((ImageEntry) this.currentEntry).getDataSet()) { 782 displayImages(null); 783 } 784 removedData.removeImageDataUpdateListener(this); 785 } 786 } 787 788 @Override 789 public void layerOrderChanged(LayerOrderChangeEvent e) { 790 // ignored 791 } 792 793 @Override 794 public void activeOrEditLayerChanged(ActiveLayerChangeEvent e) { 795 if (!MainApplication.worker.isShutdown()) { 796 showLayer(e.getSource().getActiveLayer()); 797 } 798 } 799 800 private void registerOnLayer(Layer layer) { 801 if (layer instanceof GeoImageLayer) { 802 ((GeoImageLayer) layer).getImageData().addImageDataUpdateListener(this); 803 } 804 } 805 806 private void showLayer(Layer newLayer) { 807 if (this.currentEntry == null && newLayer instanceof GeoImageLayer) { 808 ImageData imageData = ((GeoImageLayer) newLayer).getImageData(); 809 imageData.setSelectedImage(imageData.getFirstImage()); 810 } 811 } 812 813 private void cancelLoadingImage() { 814 if (imgLoadingFuture != null) { 815 imgLoadingFuture.cancel(false); 816 imgLoadingFuture = null; 817 } 818 } 819 820 @Override 821 public void selectedImageChanged(ImageData data) { 822 displayImages(new ArrayList<>(data.getSelectedImages())); 823 } 824 825 @Override 826 public void imageDataUpdated(ImageData data) { 827 displayImages(new ArrayList<>(data.getSelectedImages())); 828 } 829}