public SaveAndUploadTask(SaveLayersModel model, ProgressMonitor monitor) { this.model = model; this.monitor = monitor; this.worker = Executors.newSingleThreadExecutor( Utils.newThreadFactory(getClass() + "-%d", Thread.NORM_PRIORITY)); }
/** Layer displaying geottaged pictures. */ public class GeoImageLayer extends Layer implements PropertyChangeListener, JumpToMarkerLayer { private static List<Action> menuAdditions = new LinkedList<>(); private static volatile List<MapMode> supportedMapModes; List<ImageEntry> data; GpxLayer gpxLayer; private final Icon icon = ImageProvider.get("dialogs/geoimage/photo-marker"); private final Icon selectedIcon = ImageProvider.get("dialogs/geoimage/photo-marker-selected"); private int currentPhoto = -1; boolean useThumbs; private final ExecutorService thumbsLoaderExecutor = Executors.newSingleThreadExecutor( Utils.newThreadFactory("thumbnail-loader-%d", Thread.MIN_PRIORITY)); private ThumbsLoader thumbsloader; private boolean thumbsLoaderRunning; volatile boolean thumbsLoaded; private BufferedImage offscreenBuffer; boolean updateOffscreenBuffer = true; private MouseAdapter mouseAdapter; private MapModeChangeListener mapModeListener; /** * Constructs a new {@code GeoImageLayer}. * * @param data The list of images to display * @param gpxLayer The associated GPX layer */ public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer) { this(data, gpxLayer, null, false); } /** * Constructs a new {@code GeoImageLayer}. * * @param data The list of images to display * @param gpxLayer The associated GPX layer * @param name Layer name * @since 6392 */ public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, final String name) { this(data, gpxLayer, name, false); } /** * Constructs a new {@code GeoImageLayer}. * * @param data The list of images to display * @param gpxLayer The associated GPX layer * @param useThumbs Thumbnail display flag * @since 6392 */ public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, boolean useThumbs) { this(data, gpxLayer, null, useThumbs); } /** * Constructs a new {@code GeoImageLayer}. * * @param data The list of images to display * @param gpxLayer The associated GPX layer * @param name Layer name * @param useThumbs Thumbnail display flag * @since 6392 */ public GeoImageLayer( final List<ImageEntry> data, GpxLayer gpxLayer, final String name, boolean useThumbs) { super(name != null ? name : tr("Geotagged Images")); if (data != null) { Collections.sort(data); } this.data = data; this.gpxLayer = gpxLayer; this.useThumbs = useThumbs; } /** * Loads a set of images, while displaying a dialog that indicates what the plugin is currently * doing. In facts, this object is instantiated with a list of files. These files may be JPEG * files or directories. In case of directories, they are scanned to find all the images they * contain. Then all the images that have be found are loaded as ImageEntry instances. */ static final class Loader extends PleaseWaitRunnable { private boolean canceled; private GeoImageLayer layer; private final Collection<File> selection; private final Set<String> loadedDirectories = new HashSet<>(); private final Set<String> errorMessages; private final GpxLayer gpxLayer; Loader(Collection<File> selection, GpxLayer gpxLayer) { super(tr("Extracting GPS locations from EXIF")); this.selection = selection; this.gpxLayer = gpxLayer; errorMessages = new LinkedHashSet<>(); } protected void rememberError(String message) { this.errorMessages.add(message); } @Override protected void realRun() throws IOException { progressMonitor.subTask(tr("Starting directory scan")); Collection<File> files = new ArrayList<>(); try { addRecursiveFiles(files, selection); } catch (IllegalStateException e) { rememberError(e.getMessage()); } if (canceled) return; progressMonitor.subTask(tr("Read photos...")); progressMonitor.setTicksCount(files.size()); progressMonitor.subTask(tr("Read photos...")); progressMonitor.setTicksCount(files.size()); // read the image files List<ImageEntry> entries = new ArrayList<>(files.size()); for (File f : files) { if (canceled) { break; } progressMonitor.subTask(tr("Reading {0}...", f.getName())); progressMonitor.worked(1); ImageEntry e = new ImageEntry(f); e.extractExif(); entries.add(e); } layer = new GeoImageLayer(entries, gpxLayer); files.clear(); } private void addRecursiveFiles(Collection<File> files, Collection<File> sel) { boolean nullFile = false; for (File f : sel) { if (canceled) { break; } if (f == null) { nullFile = true; } else if (f.isDirectory()) { String canonical = null; try { canonical = f.getCanonicalPath(); } catch (IOException e) { Main.error(e); rememberError( tr("Unable to get canonical path for directory {0}\n", f.getAbsolutePath())); } if (canonical == null || loadedDirectories.contains(canonical)) { continue; } else { loadedDirectories.add(canonical); } File[] children = f.listFiles(JpgImporter.FILE_FILTER_WITH_FOLDERS); if (children != null) { progressMonitor.subTask(tr("Scanning directory {0}", f.getPath())); addRecursiveFiles(files, Arrays.asList(children)); } else { rememberError(tr("Error while getting files from directory {0}\n", f.getPath())); } } else { files.add(f); } } if (nullFile) { throw new IllegalStateException(tr("One of the selected files was null")); } } protected String formatErrorMessages() { StringBuilder sb = new StringBuilder(); sb.append("<html>"); if (errorMessages.size() == 1) { sb.append(errorMessages.iterator().next()); } else { sb.append(Utils.joinAsHtmlUnorderedList(errorMessages)); } sb.append("</html>"); return sb.toString(); } @Override protected void finish() { if (!errorMessages.isEmpty()) { JOptionPane.showMessageDialog( Main.parent, formatErrorMessages(), tr("Error"), JOptionPane.ERROR_MESSAGE); } if (layer != null) { Main.main.addLayer(layer); if (!canceled && layer.data != null && !layer.data.isEmpty()) { boolean noGeotagFound = true; for (ImageEntry e : layer.data) { if (e.getPos() != null) { noGeotagFound = false; } } if (noGeotagFound) { new CorrelateGpxWithImages(layer).actionPerformed(null); } } } } @Override protected void cancel() { canceled = true; } } public static void create(Collection<File> files, GpxLayer gpxLayer) { Main.worker.execute(new Loader(files, gpxLayer)); } @Override public Icon getIcon() { return ImageProvider.get("dialogs/geoimage"); } public static void registerMenuAddition(Action addition) { menuAdditions.add(addition); } @Override public Action[] getMenuEntries() { List<Action> entries = new ArrayList<>(); entries.add(LayerListDialog.getInstance().createShowHideLayerAction()); entries.add(LayerListDialog.getInstance().createDeleteLayerAction()); entries.add(LayerListDialog.getInstance().createMergeLayerAction(this)); entries.add(new RenameLayerAction(null, this)); entries.add(SeparatorLayerAction.INSTANCE); entries.add(new CorrelateGpxWithImages(this)); entries.add(new ShowThumbnailAction(this)); if (!menuAdditions.isEmpty()) { entries.add(SeparatorLayerAction.INSTANCE); entries.addAll(menuAdditions); } entries.add(SeparatorLayerAction.INSTANCE); entries.add(new JumpToNextMarker(this)); entries.add(new JumpToPreviousMarker(this)); entries.add(SeparatorLayerAction.INSTANCE); entries.add(new LayerListPopup.InfoAction(this)); return entries.toArray(new Action[entries.size()]); } /** * Prepare the string that is displayed if layer information is requested. * * @return String with layer information */ private String infoText() { int tagged = 0; int newdata = 0; int n = 0; if (data != null) { n = data.size(); for (ImageEntry e : data) { if (e.getPos() != null) { tagged++; } if (e.hasNewGpsData()) { newdata++; } } } return "<html>" + trn("{0} image loaded.", "{0} images loaded.", n, n) + ' ' + trn("{0} was found to be GPS tagged.", "{0} were found to be GPS tagged.", tagged, tagged) + (newdata > 0 ? "<br>" + trn("{0} has updated GPS data.", "{0} have updated GPS data.", newdata, newdata) : "") + "</html>"; } @Override public Object getInfoComponent() { return infoText(); } @Override public String getToolTipText() { return infoText(); } @Override public boolean isMergable(Layer other) { return other instanceof GeoImageLayer; } @Override public void mergeFrom(Layer from) { GeoImageLayer l = (GeoImageLayer) from; // Stop to load thumbnails on both layers. Thumbnail loading will continue the next time // the layer is painted. stopLoadThumbs(); l.stopLoadThumbs(); final ImageEntry selected = l.data != null && l.currentPhoto >= 0 ? l.data.get(l.currentPhoto) : null; if (l.data != null) { data.addAll(l.data); } Collections.sort(data); // Supress the double photos. if (data.size() > 1) { ImageEntry cur; ImageEntry prev = data.get(data.size() - 1); for (int i = data.size() - 2; i >= 0; i--) { cur = data.get(i); if (cur.getFile().equals(prev.getFile())) { data.remove(i); } else { prev = cur; } } } if (selected != null && !data.isEmpty()) { GuiHelper.runInEDTAndWait( new Runnable() { @Override public void run() { for (int i = 0; i < data.size(); i++) { if (selected.equals(data.get(i))) { currentPhoto = i; ImageViewerDialog.showImage(GeoImageLayer.this, data.get(i)); break; } } } }); } setName(l.getName()); thumbsLoaded &= l.thumbsLoaded; } private static Dimension scaledDimension(Image thumb) { final double d = Main.map.mapView.getDist100Pixel(); final double size = 10 /*meter*/; /* size of the photo on the map */ double s = size * 100 /*px*/ / d; final double sMin = ThumbsLoader.minSize; final double sMax = ThumbsLoader.maxSize; if (s < sMin) { s = sMin; } if (s > sMax) { s = sMax; } final double f = s / sMax; /* scale factor */ if (thumb == null) return null; return new Dimension( (int) Math.round(f * thumb.getWidth(null)), (int) Math.round(f * thumb.getHeight(null))); } @Override public void paint(Graphics2D g, MapView mv, Bounds bounds) { int width = mv.getWidth(); int height = mv.getHeight(); Rectangle clip = g.getClipBounds(); if (useThumbs) { if (!thumbsLoaded) { startLoadThumbs(); } if (null == offscreenBuffer || offscreenBuffer.getWidth() != width // reuse the old buffer if possible || offscreenBuffer.getHeight() != height) { offscreenBuffer = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); updateOffscreenBuffer = true; } if (updateOffscreenBuffer) { Graphics2D tempG = offscreenBuffer.createGraphics(); tempG.setColor(new Color(0, 0, 0, 0)); Composite saveComp = tempG.getComposite(); tempG.setComposite(AlphaComposite.Clear); // remove the old images tempG.fillRect(0, 0, width, height); tempG.setComposite(saveComp); if (data != null) { for (ImageEntry e : data) { if (e.getPos() == null) { continue; } Point p = mv.getPoint(e.getPos()); if (e.hasThumbnail()) { Dimension d = scaledDimension(e.getThumbnail()); Rectangle target = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height); if (clip.intersects(target)) { tempG.drawImage( e.getThumbnail(), target.x, target.y, target.width, target.height, null); } } else { // thumbnail not loaded yet icon.paintIcon( mv, tempG, p.x - icon.getIconWidth() / 2, p.y - icon.getIconHeight() / 2); } } } updateOffscreenBuffer = false; } g.drawImage(offscreenBuffer, 0, 0, null); } else if (data != null) { for (ImageEntry e : data) { if (e.getPos() == null) { continue; } Point p = mv.getPoint(e.getPos()); icon.paintIcon(mv, g, p.x - icon.getIconWidth() / 2, p.y - icon.getIconHeight() / 2); } } if (currentPhoto >= 0 && currentPhoto < data.size()) { ImageEntry e = data.get(currentPhoto); if (e.getPos() != null) { Point p = mv.getPoint(e.getPos()); int imgWidth; int imgHeight; if (useThumbs && e.hasThumbnail()) { Dimension d = scaledDimension(e.getThumbnail()); imgWidth = d.width; imgHeight = d.height; } else { imgWidth = selectedIcon.getIconWidth(); imgHeight = selectedIcon.getIconHeight(); } if (e.getExifImgDir() != null) { // Multiplier must be larger than sqrt(2)/2=0.71. double arrowlength = Math.max(25, Math.max(imgWidth, imgHeight) * 0.85); double arrowwidth = arrowlength / 1.4; double dir = e.getExifImgDir(); // Rotate 90 degrees CCW double headdir = (dir < 90) ? dir + 270 : dir - 90; double leftdir = (headdir < 90) ? headdir + 270 : headdir - 90; double rightdir = (headdir > 270) ? headdir - 270 : headdir + 90; double ptx = p.x + Math.cos(Math.toRadians(headdir)) * arrowlength; double pty = p.y + Math.sin(Math.toRadians(headdir)) * arrowlength; double ltx = p.x + Math.cos(Math.toRadians(leftdir)) * arrowwidth / 2; double lty = p.y + Math.sin(Math.toRadians(leftdir)) * arrowwidth / 2; double rtx = p.x + Math.cos(Math.toRadians(rightdir)) * arrowwidth / 2; double rty = p.y + Math.sin(Math.toRadians(rightdir)) * arrowwidth / 2; g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.setColor(new Color(255, 255, 255, 192)); int[] xar = {(int) ltx, (int) ptx, (int) rtx, (int) ltx}; int[] yar = {(int) lty, (int) pty, (int) rty, (int) lty}; g.fillPolygon(xar, yar, 4); g.setColor(Color.black); g.setStroke(new BasicStroke(1.2f)); g.drawPolyline(xar, yar, 3); } if (useThumbs && e.hasThumbnail()) { g.setColor(new Color(128, 0, 0, 122)); g.fillRect(p.x - imgWidth / 2, p.y - imgHeight / 2, imgWidth, imgHeight); } else { selectedIcon.paintIcon(mv, g, p.x - imgWidth / 2, p.y - imgHeight / 2); } } } } @Override public void visitBoundingBox(BoundingXYVisitor v) { for (ImageEntry e : data) { v.visit(e.getPos()); } } /** Shows next photo. */ public void showNextPhoto() { if (data != null && !data.isEmpty()) { currentPhoto++; if (currentPhoto >= data.size()) { currentPhoto = data.size() - 1; } ImageViewerDialog.showImage(this, data.get(currentPhoto)); } else { currentPhoto = -1; } Main.map.repaint(); } /** Shows previous photo. */ public void showPreviousPhoto() { if (data != null && !data.isEmpty()) { currentPhoto--; if (currentPhoto < 0) { currentPhoto = 0; } ImageViewerDialog.showImage(this, data.get(currentPhoto)); } else { currentPhoto = -1; } Main.map.repaint(); } /** Shows first photo. */ public void showFirstPhoto() { if (data != null && !data.isEmpty()) { currentPhoto = 0; ImageViewerDialog.showImage(this, data.get(currentPhoto)); } else { currentPhoto = -1; } Main.map.repaint(); } /** Shows last photo. */ public void showLastPhoto() { if (data != null && !data.isEmpty()) { currentPhoto = data.size() - 1; ImageViewerDialog.showImage(this, data.get(currentPhoto)); } else { currentPhoto = -1; } Main.map.repaint(); } public void checkPreviousNextButtons() { ImageViewerDialog.setNextEnabled(data != null && currentPhoto < data.size() - 1); ImageViewerDialog.setPreviousEnabled(currentPhoto > 0); } public void removeCurrentPhoto() { if (data != null && !data.isEmpty() && currentPhoto >= 0 && currentPhoto < data.size()) { data.remove(currentPhoto); if (currentPhoto >= data.size()) { currentPhoto = data.size() - 1; } if (currentPhoto >= 0) { ImageViewerDialog.showImage(this, data.get(currentPhoto)); } else { ImageViewerDialog.showImage(this, null); } updateOffscreenBuffer = true; Main.map.repaint(); } } public void removeCurrentPhotoFromDisk() { ImageEntry toDelete; if (data != null && !data.isEmpty() && currentPhoto >= 0 && currentPhoto < data.size()) { toDelete = data.get(currentPhoto); int result = new ExtendedDialog( Main.parent, tr("Delete image file from disk"), new String[] {tr("Cancel"), tr("Delete")}) .setButtonIcons(new String[] {"cancel", "dialogs/delete"}) .setContent( new JLabel( tr( "<html><h3>Delete the file {0} from disk?<p>The image file will be permanently lost!</h3></html>", toDelete.getFile().getName()), ImageProvider.get("dialogs/geoimage/deletefromdisk"), SwingConstants.LEFT)) .toggleEnable("geoimage.deleteimagefromdisk") .setCancelButton(1) .setDefaultButton(2) .showDialog() .getValue(); if (result == 2) { data.remove(currentPhoto); if (currentPhoto >= data.size()) { currentPhoto = data.size() - 1; } if (currentPhoto >= 0) { ImageViewerDialog.showImage(this, data.get(currentPhoto)); } else { ImageViewerDialog.showImage(this, null); } if (Utils.deleteFile(toDelete.getFile())) { Main.info("File " + toDelete.getFile() + " deleted. "); } else { JOptionPane.showMessageDialog( Main.parent, tr("Image file could not be deleted."), tr("Error"), JOptionPane.ERROR_MESSAGE); } updateOffscreenBuffer = true; Main.map.repaint(); } } } public void copyCurrentPhotoPath() { if (data != null && !data.isEmpty() && currentPhoto >= 0 && currentPhoto < data.size()) { Utils.copyToClipboard(data.get(currentPhoto).getFile().toString()); } } /** * Removes a photo from the list of images by index. * * @param idx Image index * @since 6392 */ public void removePhotoByIdx(int idx) { if (idx >= 0 && data != null && idx < data.size()) { data.remove(idx); } } /** * Returns the image that matches the position of the mouse event. * * @param evt Mouse event * @return Image at mouse position, or {@code null} if there is no image at the mouse position * @since 6392 */ public ImageEntry getPhotoUnderMouse(MouseEvent evt) { if (data != null) { for (int idx = data.size() - 1; idx >= 0; --idx) { ImageEntry img = data.get(idx); if (img.getPos() == null) { continue; } Point p = Main.map.mapView.getPoint(img.getPos()); Rectangle r; if (useThumbs && img.hasThumbnail()) { Dimension d = scaledDimension(img.getThumbnail()); r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height); } else { r = new Rectangle( p.x - icon.getIconWidth() / 2, p.y - icon.getIconHeight() / 2, icon.getIconWidth(), icon.getIconHeight()); } if (r.contains(evt.getPoint())) { return img; } } } return null; } /** * Clears the currentPhoto, i.e. remove select marker, and optionally repaint. * * @param repaint Repaint flag * @since 6392 */ public void clearCurrentPhoto(boolean repaint) { currentPhoto = -1; if (repaint) { updateBufferAndRepaint(); } } /** * Clears the currentPhoto of the other GeoImageLayer's. Otherwise there could be multiple * selected photos. */ private void clearOtherCurrentPhotos() { for (GeoImageLayer layer : Main.map.mapView.getLayersOfType(GeoImageLayer.class)) { if (layer != this) { layer.clearCurrentPhoto(false); } } } /** * Registers a map mode for which the functionality of this layer should be available. * * @param mapMode Map mode to be registered * @since 6392 */ public static void registerSupportedMapMode(MapMode mapMode) { if (supportedMapModes == null) { supportedMapModes = new ArrayList<>(); } supportedMapModes.add(mapMode); } /** * Determines if the functionality of this layer is available in the specified map mode. {@link * SelectAction} and {@link LassoModeAction} are supported by default, other map modes can be * registered. * * @param mapMode Map mode to be checked * @return {@code true} if the map mode is supported, {@code false} otherwise */ private static boolean isSupportedMapMode(MapMode mapMode) { if (mapMode instanceof SelectAction || mapMode instanceof LassoModeAction) { return true; } if (supportedMapModes != null) { for (MapMode supmmode : supportedMapModes) { if (mapMode == supmmode) { return true; } } } return false; } @Override public void hookUpMapView() { mouseAdapter = new MouseAdapter() { private boolean isMapModeOk() { return Main.map.mapMode == null || isSupportedMapMode(Main.map.mapMode); } @Override public void mousePressed(MouseEvent e) { if (e.getButton() != MouseEvent.BUTTON1) return; if (isVisible() && isMapModeOk()) { Main.map.mapView.repaint(); } } @Override public void mouseReleased(MouseEvent ev) { if (ev.getButton() != MouseEvent.BUTTON1) return; if (data == null || !isVisible() || !isMapModeOk()) return; for (int i = data.size() - 1; i >= 0; --i) { ImageEntry e = data.get(i); if (e.getPos() == null) { continue; } Point p = Main.map.mapView.getPoint(e.getPos()); Rectangle r; if (useThumbs && e.hasThumbnail()) { Dimension d = scaledDimension(e.getThumbnail()); r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height); } else { r = new Rectangle( p.x - icon.getIconWidth() / 2, p.y - icon.getIconHeight() / 2, icon.getIconWidth(), icon.getIconHeight()); } if (r.contains(ev.getPoint())) { clearOtherCurrentPhotos(); currentPhoto = i; ImageViewerDialog.showImage(GeoImageLayer.this, e); Main.map.repaint(); break; } } } }; mapModeListener = new MapModeChangeListener() { @Override public void mapModeChange(MapMode oldMapMode, MapMode newMapMode) { if (newMapMode == null || isSupportedMapMode(newMapMode)) { Main.map.mapView.addMouseListener(mouseAdapter); } else { Main.map.mapView.removeMouseListener(mouseAdapter); } } }; MapFrame.addMapModeChangeListener(mapModeListener); mapModeListener.mapModeChange(null, Main.map.mapMode); MapView.addLayerChangeListener( new LayerChangeListener() { @Override public void activeLayerChange(Layer oldLayer, Layer newLayer) { if (newLayer == GeoImageLayer.this) { // only in select mode it is possible to click the images Main.map.selectSelectTool(false); } } @Override public void layerAdded(Layer newLayer) {} @Override public void layerRemoved(Layer oldLayer) { if (oldLayer == GeoImageLayer.this) { stopLoadThumbs(); Main.map.mapView.removeMouseListener(mouseAdapter); MapFrame.removeMapModeChangeListener(mapModeListener); currentPhoto = -1; if (data != null) { data.clear(); } data = null; // stop listening to layer change events MapView.removeLayerChangeListener(this); } } }); Main.map.mapView.addPropertyChangeListener(this); if (Main.map.getToggleDialog(ImageViewerDialog.class) == null) { ImageViewerDialog.newInstance(); Main.map.addToggleDialog(ImageViewerDialog.getInstance()); } } @Override public void propertyChange(PropertyChangeEvent evt) { if (NavigatableComponent.PROPNAME_CENTER.equals(evt.getPropertyName()) || NavigatableComponent.PROPNAME_SCALE.equals(evt.getPropertyName())) { updateOffscreenBuffer = true; } } /** Start to load thumbnails. */ public synchronized void startLoadThumbs() { if (useThumbs && !thumbsLoaded && !thumbsLoaderRunning) { stopLoadThumbs(); thumbsloader = new ThumbsLoader(this); thumbsLoaderExecutor.submit(thumbsloader); thumbsLoaderRunning = true; } } /** * Stop to load thumbnails. * * <p>Can be called at any time to make sure that the thumbnail loader is stopped. */ public synchronized void stopLoadThumbs() { if (thumbsloader != null) { thumbsloader.stop = true; } thumbsLoaderRunning = false; } /** * Called to signal that the loading of thumbnails has finished. * * <p>Usually called from {@link ThumbsLoader} in another thread. */ public void thumbsLoaded() { thumbsLoaded = true; } public void updateBufferAndRepaint() { updateOffscreenBuffer = true; Main.map.mapView.repaint(); } /** * Get list of images in layer. * * @return List of images in layer */ public List<ImageEntry> getImages() { if (data == null) { return Collections.emptyList(); } List<ImageEntry> copy = new ArrayList<>(data.size()); for (ImageEntry ie : data) { copy.add(ie); } return copy; } /** * Returns the associated GPX layer. * * @return The associated GPX layer */ public GpxLayer getGpxLayer() { return gpxLayer; } @Override public void jumpToNextMarker() { showNextPhoto(); } @Override public void jumpToPreviousMarker() { showPreviousPhoto(); } /** * Returns the current thumbnail display status. {@code true}: thumbnails are displayed, {@code * false}: an icon is displayed instead of thumbnails. * * @return Current thumbnail display status * @since 6392 */ public boolean isUseThumbs() { return useThumbs; } /** * Enables or disables the display of thumbnails. Does not update the display. * * @param useThumbs New thumbnail display status * @since 6392 */ public void setUseThumbs(boolean useThumbs) { this.useThumbs = useThumbs; if (useThumbs && !thumbsLoaded) { startLoadThumbs(); } else if (!useThumbs) { stopLoadThumbs(); } } }