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