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