source: josm/trunk/src/org/openstreetmap/josm/gui/layer/geoimage/GeoImageLayer.java@ 2646

Last change on this file since 2646 was 2646, checked in by bastiK, 14 years ago

geoimage: select correct gpx track by default in the drop down menu (and not always the first)

  • Property svn:eol-style set to native
File size: 25.6 KB
Line 
1// License: GPL. See LICENSE file for details.
2// Copyright 2007 by Christian Gallioz (aka khris78)
3// Parts of code from Geotagged plugin (by Rob Neild)
4// and the core JOSM source code (by Immanuel Scholz and others)
5
6package org.openstreetmap.josm.gui.layer.geoimage;
7
8import static org.openstreetmap.josm.tools.I18n.tr;
9import static org.openstreetmap.josm.tools.I18n.trn;
10
11import java.awt.AlphaComposite;
12import java.awt.BorderLayout;
13import java.awt.Color;
14import java.awt.Component;
15import java.awt.Composite;
16import java.awt.Dimension;
17import java.awt.Graphics2D;
18import java.awt.Image;
19import java.awt.Point;
20import java.awt.Rectangle;
21import java.awt.event.MouseAdapter;
22import java.awt.event.MouseEvent;
23import java.awt.image.BufferedImage;
24import java.beans.PropertyChangeEvent;
25import java.beans.PropertyChangeListener;
26import java.io.File;
27import java.io.IOException;
28import java.text.ParseException;
29import java.util.ArrayList;
30import java.util.Arrays;
31import java.util.Collection;
32import java.util.Collections;
33import java.util.Date;
34import java.util.HashSet;
35import java.util.LinkedHashSet;
36import java.util.List;
37
38import javax.swing.Icon;
39import javax.swing.JLabel;
40import javax.swing.JMenuItem;
41import javax.swing.JOptionPane;
42import javax.swing.JPanel;
43import javax.swing.JSeparator;
44import javax.swing.SwingConstants;
45
46import org.openstreetmap.josm.Main;
47import org.openstreetmap.josm.actions.RenameLayerAction;
48import org.openstreetmap.josm.actions.mapmode.MapMode;
49import org.openstreetmap.josm.data.Bounds;
50import org.openstreetmap.josm.data.coor.CachedLatLon;
51import org.openstreetmap.josm.data.coor.LatLon;
52import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
53import org.openstreetmap.josm.gui.ExtendedDialog;
54import org.openstreetmap.josm.gui.MapFrame;
55import org.openstreetmap.josm.gui.MapFrame.MapModeChangeListener;
56import org.openstreetmap.josm.gui.MapView;
57import org.openstreetmap.josm.gui.PleaseWaitRunnable;
58import org.openstreetmap.josm.gui.MapView.LayerChangeListener;
59import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
60import org.openstreetmap.josm.gui.layer.GpxLayer;
61import org.openstreetmap.josm.gui.layer.Layer;
62import org.openstreetmap.josm.tools.ExifReader;
63import org.openstreetmap.josm.tools.ImageProvider;
64
65import com.drew.imaging.jpeg.JpegMetadataReader;
66import com.drew.lang.Rational;
67import com.drew.metadata.Directory;
68import com.drew.metadata.Metadata;
69import com.drew.metadata.exif.GpsDirectory;
70
71public class GeoImageLayer extends Layer implements PropertyChangeListener {
72
73 List<ImageEntry> data;
74 GpxLayer gpxLayer;
75
76 private Icon icon = ImageProvider.get("dialogs/geoimage/photo-marker");
77 private Icon selectedIcon = ImageProvider.get("dialogs/geoimage/photo-marker-selected");
78
79 private int currentPhoto = -1;
80
81 // These are used by the auto-guess function to store the result,
82 // so when the dialig is re-opened the users modifications don't
83 // get overwritten
84 public boolean hasTimeoffset = false;
85 public long timeoffset = 0;
86
87 boolean useThumbs = false;
88 ThumbsLoader thumbsloader;
89 private BufferedImage offscreenBuffer;
90 boolean updateOffscreenBuffer = true;
91
92 /*
93 * Stores info about each image
94 */
95
96 static final class ImageEntry implements Comparable<ImageEntry> {
97 File file;
98 Date time;
99 LatLon exifCoor;
100 CachedLatLon pos;
101 Image thumbnail;
102 /** Speed in kilometer per second */
103 Double speed;
104 /** Elevation (altitude) in meters */
105 Double elevation;
106
107 public void setCoor(LatLon latlon)
108 {
109 pos = new CachedLatLon(latlon);
110 }
111 public int compareTo(ImageEntry image) {
112 if (time != null && image.time != null)
113 return time.compareTo(image.time);
114 else if (time == null && image.time == null)
115 return 0;
116 else if (time == null)
117 return -1;
118 else
119 return 1;
120 }
121 }
122
123 /** Loads a set of images, while displaying a dialog that indicates what the plugin is currently doing.
124 * In facts, this object is instantiated with a list of files. These files may be JPEG files or
125 * directories. In case of directories, they are scanned to find all the images they contain.
126 * Then all the images that have be found are loaded as ImageEntry instances.
127 */
128 private static final class Loader extends PleaseWaitRunnable {
129
130 private boolean cancelled = false;
131 private GeoImageLayer layer;
132 private Collection<File> selection;
133 private HashSet<String> loadedDirectories = new HashSet<String>();
134 private LinkedHashSet<String> errorMessages;
135 private GpxLayer gpxLayer;
136
137 protected void rememberError(String message) {
138 this.errorMessages.add(message);
139 }
140
141 public Loader(Collection<File> selection, GpxLayer gpxLayer) {
142 super(tr("Extracting GPS locations from EXIF"));
143 this.selection = selection;
144 this.gpxLayer = gpxLayer;
145 errorMessages = new LinkedHashSet<String>();
146 }
147
148 @Override protected void realRun() throws IOException {
149
150 progressMonitor.subTask(tr("Starting directory scan"));
151 Collection<File> files = new ArrayList<File>();
152 try {
153 addRecursiveFiles(files, selection);
154 } catch(NullPointerException npe) {
155 rememberError(tr("One of the selected files was null"));
156 }
157
158 if (cancelled)
159 return;
160 progressMonitor.subTask(tr("Read photos..."));
161 progressMonitor.setTicksCount(files.size());
162
163 progressMonitor.subTask(tr("Read photos..."));
164 progressMonitor.setTicksCount(files.size());
165
166 // read the image files
167 List<ImageEntry> data = new ArrayList<ImageEntry>(files.size());
168
169 for (File f : files) {
170
171 if (cancelled) {
172 break;
173 }
174
175 progressMonitor.subTask(tr("Reading {0}...", f.getName()));
176 progressMonitor.worked(1);
177
178 ImageEntry e = new ImageEntry();
179
180 // Changed to silently cope with no time info in exif. One case
181 // of person having time that couldn't be parsed, but valid GPS info
182
183 try {
184 e.time = ExifReader.readTime(f);
185 } catch (ParseException e1) {
186 e.time = null;
187 }
188 e.file = f;
189 extractExif(e);
190 data.add(e);
191 }
192 layer = new GeoImageLayer(data, gpxLayer);
193 files.clear();
194 }
195
196 private void addRecursiveFiles(Collection<File> files, Collection<File> sel) {
197 boolean nullFile = false;
198
199 for (File f : sel) {
200
201 if(cancelled) {
202 break;
203 }
204
205 if (f == null) {
206 nullFile = true;
207
208 } else if (f.isDirectory()) {
209 String canonical = null;
210 try {
211 canonical = f.getCanonicalPath();
212 } catch (IOException e) {
213 e.printStackTrace();
214 rememberError(tr("Unable to get canonical path for directory {0}\n",
215 f.getAbsolutePath()));
216 }
217
218 if (canonical == null || loadedDirectories.contains(canonical)) {
219 continue;
220 } else {
221 loadedDirectories.add(canonical);
222 }
223
224 Collection<File> children = Arrays.asList(f.listFiles(JpegFileFilter.getInstance()));
225 if (children != null) {
226 progressMonitor.subTask(tr("Scanning directory {0}", f.getPath()));
227 try {
228 addRecursiveFiles(files, children);
229 } catch(NullPointerException npe) {
230 npe.printStackTrace();
231 rememberError(tr("Found null file in directory {0}\n", f.getPath()));
232 }
233 } else {
234 rememberError(tr("Error while getting files from directory {0}\n", f.getPath()));
235 }
236
237 } else {
238 files.add(f);
239 }
240 }
241
242 if (nullFile)
243 throw new NullPointerException();
244 }
245
246 protected String formatErrorMessages() {
247 StringBuffer sb = new StringBuffer();
248 sb.append("<html>");
249 if (errorMessages.size() == 1) {
250 sb.append(errorMessages.iterator().next());
251 } else {
252 sb.append("<ul>");
253 for (String msg: errorMessages) {
254 sb.append("<li>").append(msg).append("</li>");
255 }
256 sb.append("/ul>");
257 }
258 sb.append("</html>");
259 return sb.toString();
260 }
261
262 @Override protected void finish() {
263 if (!errorMessages.isEmpty()) {
264 JOptionPane.showMessageDialog(
265 Main.parent,
266 formatErrorMessages(),
267 tr("Error"),
268 JOptionPane.ERROR_MESSAGE
269 );
270 }
271 if (layer != null) {
272 Main.main.addLayer(layer);
273 layer.hook_up_mouse_events(); // Main.map.mapView should exist
274 // now. Can add mouse listener
275
276 if (! cancelled && layer.data.size() > 0) {
277 boolean noGeotagFound = true;
278 for (ImageEntry e : layer.data) {
279 if (e.pos != null) {
280 noGeotagFound = false;
281 }
282 }
283 if (noGeotagFound) {
284 new CorrelateGpxWithImages(layer).actionPerformed(null);
285 }
286 }
287 }
288 }
289
290 @Override protected void cancel() {
291 cancelled = true;
292 }
293 }
294
295 private static boolean addedToggleDialog = false;
296
297 public static void create(Collection<File> files, GpxLayer gpxLayer) {
298 Loader loader = new Loader(files, gpxLayer);
299 Main.worker.execute(loader);
300 if (!addedToggleDialog) {
301 Main.map.addToggleDialog(ImageViewerDialog.getInstance());
302 addedToggleDialog = true;
303 }
304 }
305
306 private GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer) {
307
308 super(tr("Geotagged Images"));
309
310 Collections.sort(data);
311 this.data = data;
312 this.gpxLayer = gpxLayer;
313 Main.map.mapView.addPropertyChangeListener(this);
314 }
315
316 @Override
317 public Icon getIcon() {
318 return ImageProvider.get("dialogs/geoimage");
319 }
320
321 @Override
322 public Object getInfoComponent() {
323 // TODO Auto-generated method stub
324 return null;
325 }
326
327 @Override
328 public Component[] getMenuEntries() {
329
330 JMenuItem correlateItem = new JMenuItem(tr("Correlate to GPX"), ImageProvider.get("dialogs/geoimage/gpx2img"));
331 correlateItem.addActionListener(new CorrelateGpxWithImages(this));
332
333 return new Component[] {
334 new JMenuItem(LayerListDialog.getInstance().createShowHideLayerAction(this)),
335 new JMenuItem(LayerListDialog.getInstance().createDeleteLayerAction(this)),
336 new JMenuItem(new RenameLayerAction(null, this)),
337 new JSeparator(),
338 correlateItem
339 };
340 }
341
342 @Override
343 public String getToolTipText() {
344 int i = 0;
345 for (ImageEntry e : data)
346 if (e.pos != null) {
347 i++;
348 }
349 return data.size() + " " + trn("image", "images", data.size())
350 + " loaded. " + tr("{0} were found to be gps tagged.", i);
351 }
352
353 @Override
354 public boolean isMergable(Layer other) {
355 return other instanceof GeoImageLayer;
356 }
357
358 @Override
359 public void mergeFrom(Layer from) {
360 GeoImageLayer l = (GeoImageLayer) from;
361
362 ImageEntry selected = null;
363 if (l.currentPhoto >= 0) {
364 selected = l.data.get(l.currentPhoto);
365 }
366
367 data.addAll(l.data);
368 Collections.sort(data);
369
370 // Supress the double photos.
371 if (data.size() > 1) {
372 ImageEntry cur;
373 ImageEntry prev = data.get(data.size() - 1);
374 for (int i = data.size() - 2; i >= 0; i--) {
375 cur = data.get(i);
376 if (cur.file.equals(prev.file)) {
377 data.remove(i);
378 } else {
379 prev = cur;
380 }
381 }
382 }
383
384 if (selected != null) {
385 for (int i = 0; i < data.size() ; i++) {
386 if (data.get(i) == selected) {
387 currentPhoto = i;
388 ImageViewerDialog.showImage(GeoImageLayer.this, data.get(i));
389 break;
390 }
391 }
392 }
393
394 setName(l.getName());
395 }
396
397 private Dimension scaledDimension(Image thumb) {
398 final double d = Main.map.mapView.getDist100Pixel();
399 final double size = 40 /*meter*/; /* size of the photo on the map */
400 double s = size * 100 /*px*/ / d;
401
402 final double sMin = ThumbsLoader.minSize;
403 final double sMax = ThumbsLoader.maxSize;
404
405 if (s < sMin) {
406 s = sMin;
407 }
408 if (s > sMax) {
409 s = sMax;
410 }
411 final double f = s / sMax; /* scale factor */
412
413 if (thumb == null)
414 return null;
415
416 return new Dimension(
417 (int) Math.round(f * thumb.getWidth(null)),
418 (int) Math.round(f * thumb.getHeight(null)));
419 }
420
421 @Override
422 public void paint(Graphics2D g, MapView mv, Bounds bounds) {
423 int width = Main.map.mapView.getWidth();
424 int height = Main.map.mapView.getHeight();
425 Rectangle clip = g.getClipBounds();
426 if (useThumbs) {
427 if (null == offscreenBuffer || offscreenBuffer.getWidth() != width // reuse the old buffer if possible
428 || offscreenBuffer.getHeight() != height) {
429 offscreenBuffer = new BufferedImage(width, height,
430 BufferedImage.TYPE_INT_ARGB);
431 updateOffscreenBuffer = true;
432 }
433
434 if (updateOffscreenBuffer) {
435 Graphics2D tempG = offscreenBuffer.createGraphics();
436 tempG.setColor(new Color(0,0,0,0));
437 Composite saveComp = tempG.getComposite();
438 tempG.setComposite(AlphaComposite.Clear); // remove the old images
439 tempG.fillRect(0, 0, width, height);
440 tempG.setComposite(saveComp);
441
442 for (ImageEntry e : data) {
443 if (e.pos == null) {
444 continue;
445 }
446 Point p = mv.getPoint(e.pos);
447 if (e.thumbnail != null) {
448 Dimension d = scaledDimension(e.thumbnail);
449 Rectangle target = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
450 if (clip.intersects(target)) {
451 tempG.drawImage(e.thumbnail, target.x, target.y, target.width, target.height, null);
452 }
453 }
454 else { // thumbnail not loaded yet
455 icon.paintIcon(mv, tempG,
456 p.x - icon.getIconWidth() / 2,
457 p.y - icon.getIconHeight() / 2);
458 }
459 }
460 updateOffscreenBuffer = false;
461 }
462 g.drawImage(offscreenBuffer, 0, 0, null);
463 }
464 else {
465 for (ImageEntry e : data) {
466 if (e.pos == null) {
467 continue;
468 }
469 Point p = mv.getPoint(e.pos);
470 icon.paintIcon(mv, g,
471 p.x - icon.getIconWidth() / 2,
472 p.y - icon.getIconHeight() / 2);
473 }
474 }
475
476 if (currentPhoto >= 0 && currentPhoto < data.size()) {
477 ImageEntry e = data.get(currentPhoto);
478
479 if (e.pos != null) {
480 Point p = mv.getPoint(e.pos);
481
482 if (e.thumbnail != null) {
483 Dimension d = scaledDimension(e.thumbnail);
484 g.setColor(new Color(128, 0, 0, 122));
485 g.fillRect(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
486 } else {
487 selectedIcon.paintIcon(mv, g,
488 p.x - selectedIcon.getIconWidth() / 2,
489 p.y - selectedIcon.getIconHeight() / 2);
490 }
491 }
492 }
493 }
494
495 @Override
496 public void visitBoundingBox(BoundingXYVisitor v) {
497 for (ImageEntry e : data) {
498 v.visit(e.pos);
499 }
500 }
501
502 /*
503 * Extract gps from image exif
504 *
505 * If successful, fills in the LatLon and EastNorth attributes of passed in
506 * image;
507 */
508
509 private static void extractExif(ImageEntry e) {
510
511 try {
512 int deg;
513 float min, sec;
514 double lon, lat;
515
516 Metadata metadata = JpegMetadataReader.readMetadata(e.file);
517 Directory dir = metadata.getDirectory(GpsDirectory.class);
518
519 // longitude
520
521 Rational[] components = dir
522 .getRationalArray(GpsDirectory.TAG_GPS_LONGITUDE);
523
524 deg = components[0].intValue();
525 min = components[1].floatValue();
526 sec = components[2].floatValue();
527
528 lon = (deg + (min / 60) + (sec / 3600));
529
530 if (dir.getString(GpsDirectory.TAG_GPS_LONGITUDE_REF).charAt(0) == 'W') {
531 lon = -lon;
532 }
533
534 // latitude
535
536 components = dir.getRationalArray(GpsDirectory.TAG_GPS_LATITUDE);
537
538 deg = components[0].intValue();
539 min = components[1].floatValue();
540 sec = components[2].floatValue();
541
542 lat = (deg + (min / 60) + (sec / 3600));
543
544 if (dir.getString(GpsDirectory.TAG_GPS_LATITUDE_REF).charAt(0) == 'S') {
545 lat = -lat;
546 }
547
548 // Store values
549
550 e.setCoor(new LatLon(lat, lon));
551 e.exifCoor = e.pos;
552
553 } catch (Exception p) {
554 e.pos = null;
555 }
556 }
557
558 public void showNextPhoto() {
559 if (data != null && data.size() > 0) {
560 currentPhoto++;
561 if (currentPhoto >= data.size()) {
562 currentPhoto = data.size() - 1;
563 }
564 ImageViewerDialog.showImage(this, data.get(currentPhoto));
565 } else {
566 currentPhoto = -1;
567 }
568 Main.main.map.repaint();
569 }
570
571 public void showPreviousPhoto() {
572 if (data != null && data.size() > 0) {
573 currentPhoto--;
574 if (currentPhoto < 0) {
575 currentPhoto = 0;
576 }
577 ImageViewerDialog.showImage(this, data.get(currentPhoto));
578 } else {
579 currentPhoto = -1;
580 }
581 Main.main.map.repaint();
582 }
583
584 public void checkPreviousNextButtons() {
585 // System.err.println("showing image " + currentPhoto);
586 ImageViewerDialog.setNextEnabled(currentPhoto < data.size() - 1);
587 ImageViewerDialog.setPreviousEnabled(currentPhoto > 0);
588 }
589
590 public void removeCurrentPhoto() {
591 if (data != null && data.size() > 0 && currentPhoto >= 0 && currentPhoto < data.size()) {
592 data.remove(currentPhoto);
593 if (currentPhoto >= data.size()) {
594 currentPhoto = data.size() - 1;
595 }
596 if (currentPhoto >= 0) {
597 ImageViewerDialog.showImage(this, data.get(currentPhoto));
598 } else {
599 ImageViewerDialog.showImage(this, null);
600 }
601 updateOffscreenBuffer = true;
602 Main.main.map.repaint();
603 }
604 }
605
606 public void removeCurrentPhotoFromDisk() {
607 ImageEntry toDelete = null;
608 if (data != null && data.size() > 0 && currentPhoto >= 0 && currentPhoto < data.size()) {
609 toDelete = data.get(currentPhoto);
610
611 int result = new ExtendedDialog(
612 Main.parent,
613 tr("Delete image file from disk"),
614 new String[] {tr("Cancel"), tr("Delete")})
615 .setButtonIcons(new String[] {"cancel.png", "dialogs/delete.png"})
616 .setContent(new JLabel(tr("<html><h3>Delete the file {0} from the disk?<p>The image file will be permanently lost!"
617 ,toDelete.file.getName()), ImageProvider.get("dialogs/geoimage/deletefromdisk"),SwingConstants.LEFT))
618 .toggleEnable("geoimage.deleteimagefromdisk")
619 .setToggleCheckboxText(tr("Always delete and don't show this dialog again"))
620 .setCancelButton(1)
621 .setDefaultButton(2)
622 .showDialog()
623 .getValue();
624
625 if(result == 2 || result == ExtendedDialog.DialogNotShown)
626 {
627 data.remove(currentPhoto);
628 if (currentPhoto >= data.size()) {
629 currentPhoto = data.size() - 1;
630 }
631 if (currentPhoto >= 0) {
632 ImageViewerDialog.showImage(this, data.get(currentPhoto));
633 } else {
634 ImageViewerDialog.showImage(this, null);
635 }
636
637 if (toDelete.file.delete()) {
638 System.out.println("File "+toDelete.file.toString()+" deleted. ");
639 } else {
640 JOptionPane.showMessageDialog(
641 Main.parent,
642 tr("Image file could not be deleted."),
643 tr("Error"),
644 JOptionPane.ERROR_MESSAGE
645 );
646 }
647
648 updateOffscreenBuffer = true;
649 Main.main.map.repaint();
650 }
651 }
652 }
653
654 private MouseAdapter mouseAdapter = null;
655 private MapModeChangeListener mapModeListener = null;
656
657 private void hook_up_mouse_events() {
658 mouseAdapter = new MouseAdapter() {
659 @Override public void mousePressed(MouseEvent e) {
660
661 if (e.getButton() != MouseEvent.BUTTON1)
662 return;
663 if (isVisible()) {
664 Main.map.mapView.repaint();
665 }
666 }
667
668 @Override public void mouseReleased(MouseEvent ev) {
669 if (ev.getButton() != MouseEvent.BUTTON1)
670 return;
671 if (!isVisible())
672 return;
673
674 for (int i = data.size() - 1; i >= 0; --i) {
675 ImageEntry e = data.get(i);
676 if (e.pos == null) {
677 continue;
678 }
679 Point p = Main.map.mapView.getPoint(e.pos);
680 Rectangle r;
681 if (e.thumbnail != null) {
682 Dimension d = scaledDimension(e.thumbnail);
683 r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
684 } else {
685 r = new Rectangle(p.x - icon.getIconWidth() / 2,
686 p.y - icon.getIconHeight() / 2,
687 icon.getIconWidth(),
688 icon.getIconHeight());
689 }
690 if (r.contains(ev.getPoint())) {
691 currentPhoto = i;
692 ImageViewerDialog.showImage(GeoImageLayer.this, e);
693 Main.main.map.repaint();
694 break;
695 }
696 }
697 }
698 };
699
700 mapModeListener = new MapModeChangeListener() {
701 public void mapModeChange(MapMode oldMapMode, MapMode newMapMode) {
702 if (newMapMode instanceof org.openstreetmap.josm.actions.mapmode.SelectAction) {
703 Main.map.mapView.addMouseListener(mouseAdapter);
704 } else {
705 Main.map.mapView.removeMouseListener(mouseAdapter);
706 }
707 }
708 };
709
710 Main.map.addMapModeChangeListener(mapModeListener);
711 mapModeListener.mapModeChange(null, Main.map.mapMode);
712
713 MapView.addLayerChangeListener(new LayerChangeListener() {
714 public void activeLayerChange(Layer oldLayer, Layer newLayer) {
715 if (newLayer == GeoImageLayer.this) {
716 // only in select mode it is possible to click the images
717 Main.map.selectSelectTool(false);
718 }
719 }
720
721 public void layerAdded(Layer newLayer) {
722 }
723
724 public void layerRemoved(Layer oldLayer) {
725 if (oldLayer == GeoImageLayer.this) {
726 if (thumbsloader != null) {
727 thumbsloader.stop = true;
728 }
729 Main.map.mapView.removeMouseListener(mouseAdapter);
730 Main.map.removeMapModeChangeListener(mapModeListener);
731 currentPhoto = -1;
732 data.clear();
733 data = null;
734 // stop listening to layer change events
735 MapView.removeLayerChangeListener(this);
736 }
737 }
738 });
739 }
740
741 public void propertyChange(PropertyChangeEvent evt) {
742 if ("center".equals(evt.getPropertyName()) || "scale".equals(evt.getPropertyName())) {
743 updateOffscreenBuffer = true;
744 }
745 }
746}
Note: See TracBrowser for help on using the repository browser.