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