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;
015
016import javax.swing.AbstractAction;
017import javax.swing.Box;
018import javax.swing.ImageIcon;
019import javax.swing.JButton;
020import javax.swing.JComponent;
021import javax.swing.JPanel;
022import javax.swing.JToggleButton;
023
024import org.openstreetmap.josm.Main;
025import org.openstreetmap.josm.gui.MapView;
026import org.openstreetmap.josm.gui.MapView.LayerChangeListener;
027import org.openstreetmap.josm.gui.dialogs.DialogsPanel.Action;
028import org.openstreetmap.josm.gui.dialogs.ToggleDialog;
029import org.openstreetmap.josm.gui.layer.Layer;
030import org.openstreetmap.josm.tools.ImageProvider;
031import org.openstreetmap.josm.tools.Shortcut;
032import org.openstreetmap.josm.tools.date.DateUtils;
033
034public final class ImageViewerDialog extends ToggleDialog implements LayerChangeListener {
035
036    private static final String COMMAND_ZOOM = "zoom";
037    private static final String COMMAND_CENTERVIEW = "centre";
038    private static final String COMMAND_NEXT = "next";
039    private static final String COMMAND_REMOVE = "remove";
040    private static final String COMMAND_REMOVE_FROM_DISK = "removefromdisk";
041    private static final String COMMAND_PREVIOUS = "previous";
042    private static final String COMMAND_COLLAPSE = "collapse";
043    private static final String COMMAND_FIRST = "first";
044    private static final String COMMAND_LAST = "last";
045    private static final String COMMAND_COPY_PATH = "copypath";
046
047    private final ImageDisplay imgDisplay = new ImageDisplay();
048    private boolean centerView;
049
050    // Only one instance of that class is present at one time
051    private static volatile ImageViewerDialog dialog;
052
053    private boolean collapseButtonClicked;
054
055    static void newInstance() {
056        dialog = new ImageViewerDialog();
057    }
058
059    /**
060     * Replies the unique instance of this dialog
061     * @return the unique instance
062     */
063    public static ImageViewerDialog getInstance() {
064        if (dialog == null)
065            throw new AssertionError("a new instance needs to be created first");
066        return dialog;
067    }
068
069    private JButton btnNext;
070    private JButton btnPrevious;
071    private JButton btnCollapse;
072    private JToggleButton tbCentre;
073
074    private ImageViewerDialog() {
075        super(tr("Geotagged Images"), "geoimage", tr("Display geotagged images"), Shortcut.registerShortcut("tools:geotagged",
076        tr("Tool: {0}", tr("Display geotagged images")), KeyEvent.VK_Y, Shortcut.DIRECT), 200);
077        build();
078        MapView.addLayerChangeListener(this);
079    }
080
081    protected void build() {
082        JPanel content = new JPanel();
083        content.setLayout(new BorderLayout());
084
085        content.add(imgDisplay, BorderLayout.CENTER);
086
087        Dimension buttonDim = new Dimension(26, 26);
088
089        ImageAction prevAction = new ImageAction(COMMAND_PREVIOUS, ImageProvider.get("dialogs", "previous"), tr("Previous"));
090        btnPrevious = new JButton(prevAction);
091        btnPrevious.setPreferredSize(buttonDim);
092        Shortcut scPrev = Shortcut.registerShortcut(
093                "geoimage:previous", tr("Geoimage: {0}", tr("Show previous Image")), KeyEvent.VK_PAGE_UP, Shortcut.DIRECT);
094        final String APREVIOUS = "Previous Image";
095        Main.registerActionShortcut(prevAction, scPrev);
096        btnPrevious.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(scPrev.getKeyStroke(), APREVIOUS);
097        btnPrevious.getActionMap().put(APREVIOUS, prevAction);
098        btnPrevious.setEnabled(false);
099
100        final String DELETE_TEXT = tr("Remove photo from layer");
101        ImageAction delAction = new ImageAction(COMMAND_REMOVE, ImageProvider.get("dialogs", "delete"), DELETE_TEXT);
102        JButton btnDelete = new JButton(delAction);
103        btnDelete.setPreferredSize(buttonDim);
104        Shortcut scDelete = Shortcut.registerShortcut(
105                "geoimage:deleteimagefromlayer", tr("Geoimage: {0}", tr("Remove photo from layer")), KeyEvent.VK_DELETE, Shortcut.SHIFT);
106        Main.registerActionShortcut(delAction, scDelete);
107        btnDelete.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(scDelete.getKeyStroke(), DELETE_TEXT);
108        btnDelete.getActionMap().put(DELETE_TEXT, delAction);
109
110        ImageAction delFromDiskAction = new ImageAction(COMMAND_REMOVE_FROM_DISK,
111                ImageProvider.get("dialogs", "geoimage/deletefromdisk"), tr("Delete image file from disk"));
112        JButton btnDeleteFromDisk = new JButton(delFromDiskAction);
113        btnDeleteFromDisk.setPreferredSize(buttonDim);
114        Shortcut scDeleteFromDisk = Shortcut.registerShortcut(
115                "geoimage:deletefilefromdisk", tr("Geoimage: {0}", tr("Delete File from disk")), KeyEvent.VK_DELETE, Shortcut.CTRL_SHIFT);
116        final String ADELFROMDISK = "Delete image file from disk";
117        Main.registerActionShortcut(delFromDiskAction, scDeleteFromDisk);
118        btnDeleteFromDisk.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(scDeleteFromDisk.getKeyStroke(), ADELFROMDISK);
119        btnDeleteFromDisk.getActionMap().put(ADELFROMDISK, delFromDiskAction);
120
121        ImageAction copyPathAction = new ImageAction(COMMAND_COPY_PATH, ImageProvider.get("copy"), tr("Copy image path"));
122        JButton btnCopyPath = new JButton(copyPathAction);
123        btnCopyPath.setPreferredSize(buttonDim);
124        Shortcut scCopyPath = Shortcut.registerShortcut(
125                "geoimage:copypath", tr("Geoimage: {0}", tr("Copy image path")), KeyEvent.VK_C, Shortcut.ALT_CTRL_SHIFT);
126        final String ACOPYPATH = "Copy image path";
127        Main.registerActionShortcut(copyPathAction, scCopyPath);
128        btnCopyPath.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(scCopyPath.getKeyStroke(), ACOPYPATH);
129        btnCopyPath.getActionMap().put(ACOPYPATH, copyPathAction);
130
131        ImageAction nextAction = new ImageAction(COMMAND_NEXT, ImageProvider.get("dialogs", "next"), tr("Next"));
132        btnNext = new JButton(nextAction);
133        btnNext.setPreferredSize(buttonDim);
134        Shortcut scNext = Shortcut.registerShortcut(
135                "geoimage:next", tr("Geoimage: {0}", tr("Show next Image")), KeyEvent.VK_PAGE_DOWN, Shortcut.DIRECT);
136        final String ANEXT = "Next Image";
137        Main.registerActionShortcut(nextAction, scNext);
138        btnNext.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(scNext.getKeyStroke(), ANEXT);
139        btnNext.getActionMap().put(ANEXT, nextAction);
140        btnNext.setEnabled(false);
141
142        Main.registerActionShortcut(
143                new ImageAction(COMMAND_FIRST, null, null),
144                Shortcut.registerShortcut(
145                        "geoimage:first", tr("Geoimage: {0}", tr("Show first Image")), KeyEvent.VK_HOME, Shortcut.DIRECT)
146        );
147        Main.registerActionShortcut(
148                new ImageAction(COMMAND_LAST, null, null),
149                Shortcut.registerShortcut(
150                        "geoimage:last", tr("Geoimage: {0}", tr("Show last Image")), KeyEvent.VK_END, Shortcut.DIRECT)
151        );
152
153        tbCentre = new JToggleButton(new ImageAction(COMMAND_CENTERVIEW,
154                ImageProvider.get("dialogs", "centreview"), tr("Center view")));
155        tbCentre.setPreferredSize(buttonDim);
156
157        JButton btnZoomBestFit = new JButton(new ImageAction(COMMAND_ZOOM,
158                ImageProvider.get("dialogs", "zoom-best-fit"), tr("Zoom best fit and 1:1")));
159        btnZoomBestFit.setPreferredSize(buttonDim);
160
161        btnCollapse = new JButton(new ImageAction(COMMAND_COLLAPSE,
162                ImageProvider.get("dialogs", "collapse"), tr("Move dialog to the side pane")));
163        btnCollapse.setPreferredSize(new Dimension(20, 20));
164        btnCollapse.setAlignmentY(Component.TOP_ALIGNMENT);
165
166        JPanel buttons = new JPanel();
167        buttons.add(btnPrevious);
168        buttons.add(btnNext);
169        buttons.add(Box.createRigidArea(new Dimension(7, 0)));
170        buttons.add(tbCentre);
171        buttons.add(btnZoomBestFit);
172        buttons.add(Box.createRigidArea(new Dimension(7, 0)));
173        buttons.add(btnDelete);
174        buttons.add(btnDeleteFromDisk);
175        buttons.add(Box.createRigidArea(new Dimension(7, 0)));
176        buttons.add(btnCopyPath);
177
178        JPanel bottomPane = new JPanel();
179        bottomPane.setLayout(new GridBagLayout());
180        GridBagConstraints gc = new GridBagConstraints();
181        gc.gridx = 0;
182        gc.gridy = 0;
183        gc.anchor = GridBagConstraints.CENTER;
184        gc.weightx = 1;
185        bottomPane.add(buttons, gc);
186
187        gc.gridx = 1;
188        gc.gridy = 0;
189        gc.anchor = GridBagConstraints.PAGE_END;
190        gc.weightx = 0;
191        bottomPane.add(btnCollapse, gc);
192
193        content.add(bottomPane, BorderLayout.SOUTH);
194
195        createLayout(content, false, null);
196    }
197
198    @Override
199    public void destroy() {
200        MapView.removeLayerChangeListener(this);
201        super.destroy();
202    }
203
204    class ImageAction extends AbstractAction {
205        private final String action;
206
207        ImageAction(String action, ImageIcon icon, String toolTipText) {
208            this.action = action;
209            putValue(SHORT_DESCRIPTION, toolTipText);
210            putValue(SMALL_ICON, icon);
211        }
212
213        @Override
214        public void actionPerformed(ActionEvent e) {
215            if (COMMAND_NEXT.equals(action)) {
216                if (currentLayer != null) {
217                    currentLayer.showNextPhoto();
218                }
219            } else if (COMMAND_PREVIOUS.equals(action)) {
220                if (currentLayer != null) {
221                    currentLayer.showPreviousPhoto();
222                }
223            } else if (COMMAND_FIRST.equals(action) && currentLayer != null) {
224                currentLayer.showFirstPhoto();
225            } else if (COMMAND_LAST.equals(action) && currentLayer != null) {
226                currentLayer.showLastPhoto();
227            } else if (COMMAND_CENTERVIEW.equals(action)) {
228                final JToggleButton button = (JToggleButton) e.getSource();
229                centerView = button.isEnabled() && button.isSelected();
230                if (centerView && currentEntry != null && currentEntry.getPos() != null) {
231                    Main.map.mapView.zoomTo(currentEntry.getPos());
232                }
233            } else if (COMMAND_ZOOM.equals(action)) {
234                imgDisplay.zoomBestFitOrOne();
235            } else if (COMMAND_REMOVE.equals(action)) {
236                if (currentLayer != null) {
237                    currentLayer.removeCurrentPhoto();
238                }
239            } else if (COMMAND_REMOVE_FROM_DISK.equals(action)) {
240                if (currentLayer != null) {
241                    currentLayer.removeCurrentPhotoFromDisk();
242                }
243            } else if (COMMAND_COPY_PATH.equals(action)) {
244                if (currentLayer != null) {
245                    currentLayer.copyCurrentPhotoPath();
246                }
247            } else if (COMMAND_COLLAPSE.equals(action)) {
248                collapseButtonClicked = true;
249                detachedDialog.getToolkit().getSystemEventQueue().postEvent(new WindowEvent(detachedDialog, WindowEvent.WINDOW_CLOSING));
250            }
251        }
252    }
253
254    public static void showImage(GeoImageLayer layer, ImageEntry entry) {
255        getInstance().displayImage(layer, entry);
256        if (layer != null) {
257            layer.checkPreviousNextButtons();
258        } else {
259            setPreviousEnabled(false);
260            setNextEnabled(false);
261        }
262    }
263
264    /**
265     * Enables (or disables) the "Previous" button.
266     * @param value {@code true} to enable the button, {@code false} otherwise
267     */
268    public static void setPreviousEnabled(boolean value) {
269        getInstance().btnPrevious.setEnabled(value);
270    }
271
272    /**
273     * Enables (or disables) the "Next" button.
274     * @param value {@code true} to enable the button, {@code false} otherwise
275     */
276    public static void setNextEnabled(boolean value) {
277        getInstance().btnNext.setEnabled(value);
278    }
279
280    /**
281     * Enables (or disables) the "Center view" button.
282     * @param value {@code true} to enable the button, {@code false} otherwise
283     * @return the old enabled value. Can be used to restore the original enable state
284     */
285    public static synchronized boolean setCentreEnabled(boolean value) {
286        final ImageViewerDialog instance = getInstance();
287        final boolean wasEnabled = instance.tbCentre.isEnabled();
288        instance.tbCentre.setEnabled(value);
289        instance.tbCentre.getAction().actionPerformed(new ActionEvent(instance.tbCentre, 0, null));
290        return wasEnabled;
291    }
292
293    private transient GeoImageLayer currentLayer;
294    private transient ImageEntry currentEntry;
295
296    public void displayImage(GeoImageLayer layer, ImageEntry entry) {
297        boolean imageChanged;
298
299        synchronized (this) {
300            // TODO: pop up image dialog but don't load image again
301
302            imageChanged = currentEntry != entry;
303
304            if (centerView && Main.isDisplayingMapView() && entry != null && entry.getPos() != null) {
305                Main.map.mapView.zoomTo(entry.getPos());
306            }
307
308            currentLayer = layer;
309            currentEntry = entry;
310        }
311
312        if (entry != null) {
313            if (imageChanged) {
314                // Set only if the image is new to preserve zoom and position if the same image is redisplayed
315                // (e.g. to update the OSD).
316                imgDisplay.setImage(entry.getFile(), entry.getExifOrientation());
317            }
318            setTitle(tr("Geotagged Images") + (entry.getFile() != null ? " - " + entry.getFile().getName() : ""));
319            StringBuilder osd = new StringBuilder(entry.getFile() != null ? entry.getFile().getName() : "");
320            if (entry.getElevation() != null) {
321                osd.append(tr("\nAltitude: {0} m", Math.round(entry.getElevation())));
322            }
323            if (entry.getSpeed() != null) {
324                osd.append(tr("\nSpeed: {0} km/h", Math.round(entry.getSpeed())));
325            }
326            if (entry.getExifImgDir() != null) {
327                osd.append(tr("\nDirection {0}\u00b0", Math.round(entry.getExifImgDir())));
328            }
329            DateFormat dtf = DateUtils.getDateTimeFormat(DateFormat.SHORT, DateFormat.MEDIUM);
330            if (entry.hasExifTime()) {
331                osd.append(tr("\nEXIF time: {0}", dtf.format(entry.getExifTime())));
332            }
333            if (entry.hasGpsTime()) {
334                osd.append(tr("\nGPS time: {0}", dtf.format(entry.getGpsTime())));
335            }
336
337            imgDisplay.setOsdText(osd.toString());
338        } else {
339            // if this method is called to reinitialize dialog content with a blank image,
340            // do not actually show the dialog again with a blank image if currently hidden (fix #10672)
341            setTitle(tr("Geotagged Images"));
342            imgDisplay.setImage(null, null);
343            imgDisplay.setOsdText("");
344            return;
345        }
346        if (!isDialogShowing()) {
347            setIsDocked(false);     // always open a detached window when an image is clicked and dialog is closed
348            showDialog();
349        } else {
350            if (isDocked && isCollapsed) {
351                expand();
352                dialogsPanel.reconstruct(Action.COLLAPSED_TO_DEFAULT, this);
353            }
354        }
355    }
356
357    /**
358     * When an image is closed, really close it and do not pop
359     * up the side dialog.
360     */
361    @Override
362    protected boolean dockWhenClosingDetachedDlg() {
363        if (collapseButtonClicked) {
364            collapseButtonClicked = false;
365            return true;
366        }
367        return false;
368    }
369
370    @Override
371    protected void stateChanged() {
372        super.stateChanged();
373        if (btnCollapse != null) {
374            btnCollapse.setVisible(!isDocked);
375        }
376    }
377
378    /**
379     * Returns whether an image is currently displayed
380     * @return If image is currently displayed
381     */
382    public boolean hasImage() {
383        return currentEntry != null;
384    }
385
386    /**
387     * Returns the currently displayed image.
388     * @return Currently displayed image or {@code null}
389     * @since 6392
390     */
391    public static ImageEntry getCurrentImage() {
392        return getInstance().currentEntry;
393    }
394
395    /**
396     * Returns the layer associated with the image.
397     * @return Layer associated with the image
398     * @since 6392
399     */
400    public static GeoImageLayer getCurrentLayer() {
401        return getInstance().currentLayer;
402    }
403
404    @Override
405    public void activeLayerChange(Layer oldLayer, Layer newLayer) {
406        if (currentLayer == null && newLayer instanceof GeoImageLayer) {
407            ((GeoImageLayer) newLayer).showFirstPhoto();
408        }
409    }
410
411    @Override
412    public void layerAdded(Layer newLayer) {
413        if (currentLayer == null && newLayer instanceof GeoImageLayer) {
414            ((GeoImageLayer) newLayer).showFirstPhoto();
415        }
416    }
417
418    @Override
419    public void layerRemoved(Layer oldLayer) {
420        // Clear current image and layer if current layer is deleted
421        if (currentLayer != null && currentLayer.equals(oldLayer)) {
422            showImage(null, null);
423        }
424        // Check buttons state in case of layer merging
425        if (currentLayer != null && oldLayer instanceof GeoImageLayer) {
426            currentLayer.checkPreviousNextButtons();
427        }
428    }
429}