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