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}