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