kofoto.gkofoto.imagepreloader: Removed; obsoleted by
authorJoel Rosdahl <joel@rosdahl.net>
Wed, 18 Jan 2006 21:40:23 +0000 (21:40 +0000)
committerJoel Rosdahl <joel@rosdahl.net>
Wed, 18 Jan 2006 21:40:23 +0000 (21:40 +0000)
CachingPixbufLoader. Ticket #86.

kofoto.gkofoto.mainwindow: Removed ImagePreloader instance.

kofoto.gkofoto.environment: Add global CachingPixbufLoader instance to
the environment.

kofoto.gkofoto.imageview: Implemented a new ImageView with better API
and performance.

kofoto.gkofoto.singleobjectview, kofoto.gkofoto.imageversionslist and
kofoto.gkofoto.objectcollection: Use the new ImageView and
CachingPixbufLoader.

kofoto.common: Removed obsolete rectangle calculation functions; moved
to kofoto.rectangle.Rectangle.

src/packages/kofoto/common.py
src/packages/kofoto/gkofoto/environment.py
src/packages/kofoto/gkofoto/imagepreloader.py [deleted file]
src/packages/kofoto/gkofoto/imageversionslist.py
src/packages/kofoto/gkofoto/imageview.py
src/packages/kofoto/gkofoto/mainwindow.py
src/packages/kofoto/gkofoto/objectcollection.py
src/packages/kofoto/gkofoto/singleobjectview.py

index 5679bc2..958eef7 100644 (file)
@@ -6,7 +6,6 @@
 __all__ = [
     "KofotoError",
     "UnimplementedError",
-    "calculate_downscaled_size",
     "symlink_or_copy_file",
     ]
 
@@ -27,29 +26,6 @@ class UnimplementedError(KofotoError):
 ######################################################################
 ### Functions.
 
-def calculate_downscaled_size(width, height, width_limit, height_limit):
-    """Scale down width and height to fit within given limits."""
-
-    w = width
-    h = height
-    if w > width_limit:
-        h = width_limit * h // w
-        w = width_limit
-    if h > height_limit:
-        w = height_limit * w // h
-        h = height_limit
-    return w, h
-
-def calculate_rescaled_size(width, height, width_limit, height_limit):
-    """Scale up or down width and height to fit within given limits."""
-
-    w = width_limit
-    h = width_limit * height // width
-    if h > height_limit:
-        w = height_limit * w // h
-        h = height_limit
-    return w, h
-
 def symlink_or_copy_file(source, destination):
     """Create a symbolic link, or copy if support links are not supported."""
 
index 55b9079..cb73d19 100644 (file)
@@ -17,6 +17,7 @@ from kofoto.gkofoto import crashdialog
 sys.excepthook = crashdialog.show
 
 from kofoto.clientenvironment import ClientEnvironment, ClientEnvironmentError
+from kofoto.gkofoto.cachingpixbufloader import CachingPixbufLoader
 
 class WidgetsWrapper:
     def __init__(self):
@@ -45,6 +46,8 @@ class Environment(ClientEnvironment):
         self.widgets = None
         self.rotateRightCommand = None
         self.rotateLeftCommand = None
+        self.pixbufLoader = CachingPixbufLoader()
+        self.pixbufLoader.set_pixel_limit(10**7)
 
     def setup(self, bindir, isDebug=False, configFileLocation=None,
               shelfLocation=None):
diff --git a/src/packages/kofoto/gkofoto/imagepreloader.py b/src/packages/kofoto/gkofoto/imagepreloader.py
deleted file mode 100644 (file)
index a5979d3..0000000
+++ /dev/null
@@ -1,204 +0,0 @@
-# pylint: disable-msg=F0203, E0201
-
-import gobject
-import gtk
-from kofoto.timer import Timer
-from kofoto.common import calculate_downscaled_size
-
-class _MyPixbufLoader(gtk.gdk.PixbufLoader):
-    def __init__(self, *args, **kwargs):
-        gtk.gdk.PixbufLoader.__init__(self, *args, **kwargs)
-        self.__closed = False
-
-    def close(self):
-        if not self.__closed:
-            gtk.gdk.PixbufLoader.close(self)
-            self.__closed = True
-
-class _PreloadState:
-    def __init__(self, filename):
-        self.fullsizePixbuf = None
-        self.pixbufLoader = _MyPixbufLoader()
-        self.loadFinished = False # Whether loading of fullsizePixbuf is ready.
-        self.scaledPixbuf = None
-        try:
-            self.fp = open(filename, "rb")
-        except (IOError, OSError):
-            self.loadFinished = True
-            self.pixbufLoader = None
-
-    def __del__(self):
-        if self.pixbufLoader:
-            self.pixbufLoader.close()
-
-class ImagePreloader(object):
-    def __init__(self, debugPrintFunction=None):
-        if debugPrintFunction:
-            self._debugPrint = debugPrintFunction
-        else:
-            self._debugPrint = lambda x: None
-        self.__delayTimerTag = None
-        self.__idleTimerTag = None
-        # filename --> _PreloadState
-        self.__preloadStates = {}
-
-    def preloadImages(self, filenames, scaledMaxWidth, scaledMaxHeight):
-        """Preload images.
-
-        The images are loaded and stored both in a fullsize version
-        and a scaled-down version.
-
-        Note that this method discards previously preloaded images,
-        except those present in the filenames argument.
-
-        filenames -- Iterable of filenames of images to preload.
-        scaledMaxWidth -- Wanted maximum width of the scaled image.
-        scaledMaxHeight -- Wanted maximum height of the scaled image.
-        """
-        if self.__delayTimerTag != None:
-            gobject.source_remove(self.__delayTimerTag)
-        if self.__idleTimerTag != None:
-            gobject.source_remove(self.__idleTimerTag)
-
-        # Delay preloading somewhat to make display of the current
-        # image faster. Not sure whether it helps, though...
-        self.__delayTimerTag = gobject.timeout_add(
-            500,
-            self._beginPreloading,
-            filenames,
-            scaledMaxWidth,
-            scaledMaxHeight)
-
-    def clearCache(self):
-        for ps in self.__preloadStates.values():
-            if ps.pixbufLoader:
-                # Set loadFinished to avoid an extra
-                # pixbufLoader.close() by the loop in
-                # _preloadImagesWorker.
-                ps.loadFinished = True
-        self.__preloadStates = {}
-
-    def getPixbuf(self, filename, maxWidth=None, maxHeight=None):
-        """Get a pixbuf.
-
-        If maxWidth and maxHeight are None, the fullsize version is
-        returned, otherwise a scaled version no larger than maxWidth
-        and maxHeight is returned.
-
-        The pixbuf may be None if the image was unloadable.
-        """
-        if not self.__preloadStates.has_key(filename):
-            self.__preloadStates[filename] = _PreloadState(filename)
-        ps = self.__preloadStates[filename]
-        if not ps.loadFinished:
-            try:
-                ps.pixbufLoader.write(ps.fp.read())
-                ps.pixbufLoader.close()
-                ps.fullsizePixbuf = ps.pixbufLoader.get_pixbuf()
-            except (gobject.GError, OSError):
-                ps.fullsizePixbuf = None
-            ps.pixbufLoader = None
-            ps.loadFinished = True
-        if (ps.fullsizePixbuf == None or
-            (maxWidth == None and maxHeight == None) or
-            (ps.fullsizePixbuf.get_width() <= maxWidth and
-             ps.fullsizePixbuf.get_height() <= maxHeight)):
-            # Requested fullsize pixbuf or scaled pixbuf larger than
-            # fullsize.
-            return ps.fullsizePixbuf
-        else:
-            # Requested scaled pixbuf.
-            ps.scaledPixbuf = self._maybeScalePixbuf(
-                ps.fullsizePixbuf,
-                ps.scaledPixbuf,
-                maxWidth,
-                maxHeight,
-                filename)
-            return ps.scaledPixbuf
-
-    def _beginPreloading(self, filenames, scaledMaxWidth, scaledMaxHeight):
-        self.__idleTimerTag = gobject.idle_add(
-            self._preloadImagesWorker(
-                filenames, scaledMaxWidth, scaledMaxHeight).next)
-        return False
-
-    def _preloadImagesWorker(self, filenames, scaledMaxWidth, scaledMaxHeight):
-        filenames = list(filenames)
-        self._debugPrint("Preloading images %s" % str(filenames))
-
-        # Discard old preloaded images.
-        for filename in self.__preloadStates.keys():
-            if not filename in filenames:
-                del self.__preloadStates[filename]
-
-        # Preload the new images.
-        for filename in filenames:
-            if not self.__preloadStates.has_key(filename):
-                self.__preloadStates[filename] = _PreloadState(filename)
-            ps = self.__preloadStates[filename]
-            try:
-                self._debugPrint("Preloading %s" % filename)
-                timer = Timer()
-                while not ps.loadFinished: # could be set by getPixbuf
-                    data = ps.fp.read(32768)
-                    if not data:
-                        ps.pixbufLoader.close()
-                        ps.fullsizePixbuf = ps.pixbufLoader.get_pixbuf()
-                        break
-                    ps.pixbufLoader.write(data)
-                    yield True
-                self._debugPrint("Preload of %s took %.2f seconds" % (
-                    filename, timer.get()))
-            except (gobject.GError, OSError):
-                pass
-            ps.pixbufLoader = None
-            ps.loadFinished = True
-
-            ps.scaledPixbuf = self._maybeScalePixbuf(
-                ps.fullsizePixbuf,
-                ps.scaledPixbuf,
-                scaledMaxWidth,
-                scaledMaxHeight,
-                filename)
-            yield True
-
-        # We're finished.
-        self.__idleTimerTag = None
-        yield False
-
-    def _maybeScalePixbuf(self, fullsizePixbuf, scaledPixbuf,
-                          maxWidth, maxHeight, filename):
-        if not fullsizePixbuf:
-            return None
-        elif (fullsizePixbuf.get_width() <= maxWidth and
-              fullsizePixbuf.get_height() <= maxHeight):
-            return fullsizePixbuf
-        elif not (scaledPixbuf and
-                  scaledPixbuf.get_width() <= maxWidth and
-                  scaledPixbuf.get_height() <= maxHeight and
-                  (scaledPixbuf.get_width() == maxWidth or
-                   scaledPixbuf.get_height() == maxHeight)):
-            scaledWidth, scaledHeight = calculate_downscaled_size(
-                fullsizePixbuf.get_width(),
-                fullsizePixbuf.get_height(),
-                maxWidth,
-                maxHeight)
-            self._debugPrint("Scaling %s to %dx%d" % (
-                filename, scaledWidth, scaledHeight))
-            if scaledPixbuf:
-                self._debugPrint("old size: %dx%d" % (
-                    scaledPixbuf.get_width(),
-                    scaledPixbuf.get_height()))
-                self._debugPrint("new size: %dx%d" % (
-                    scaledWidth,
-                    scaledHeight))
-            timer = Timer()
-            scaledPixbuf = fullsizePixbuf.scale_simple(
-                scaledWidth,
-                scaledHeight,
-                gtk.gdk.INTERP_BILINEAR) # TODO: Make configurable.
-            self._debugPrint("Scaling of %s to %dx%d took %.2f seconds" % (
-                filename, scaledWidth, scaledHeight, timer.get()))
-            return scaledPixbuf
-        else: # Appropriately sized scaled pixbuf.
-            return scaledPixbuf
index fb3b88a..c24b1f5 100644 (file)
@@ -19,14 +19,13 @@ _imageVersionTypeToStringMap = {
 _rotationDirection = Alternative("Left", "Right")
 
 class ImageVersionsList(gtk.ScrolledWindow):
-    def __init__(self, singleObjectView, imageView):
+    def __init__(self, singleObjectView):
         gtk.ScrolledWindow.__init__(self)
         self.__menuGroup = None
         self.__imageWidgetToImageVersion = None
         self.__imageWidgetList = None
         self.__selectedImageWidgets = None
         self.__singleObjectView = singleObjectView
-        self.__imageView = imageView
         self.__vbox = gtk.VBox()
         self.__vbox.set_border_width(5)
         self.__vbox.set_spacing(10)
@@ -247,7 +246,8 @@ class ImageVersionsList(gtk.ScrolledWindow):
         assert len(self.__selectedImageWidgets) == 1
         widget = list(self.__selectedImageWidgets)[0]
         imageVersion = self.__imageWidgetToImageVersion[widget]
-        self.__imageView.loadFile(imageVersion.getLocation())
+        location = imageVersion.getLocation()
+        self.__singleObjectView._loadImageAtLocation(location)
 
     def __copyImageLocation_cb(self, widget, param):
         assert len(self.__selectedImageWidgets) > 0
@@ -345,8 +345,10 @@ class ImageVersionsList(gtk.ScrolledWindow):
                 rotateCommand = env.rotateRightCommand
             else:
                 # Can't happen.
-                assert True
-            command = rotateCommand % {"location": imageVersion.getLocation()}
+                assert False
+            location = imageVersion.getLocation()
+            env.pixbufLoader.unload_all(location)
+            command = rotateCommand % {"location": location}
             result = os.system(command.encode(env.localeEncoding))
             if result == 0:
                 imageVersion.contentChanged()
@@ -357,5 +359,4 @@ class ImageVersionsList(gtk.ScrolledWindow):
                     message_format="Failed to execute command: \"%s\"" % command)
                 dialog.run()
                 dialog.destroy()
-        env.mainwindow.getImagePreloader().clearCache()
         self.__singleObjectView.reload()
index 9c8c3a9..8dc0ca9 100644 (file)
 # pylint: disable-msg=F0203, E0201
 
+"""
+This module contains the ImageView class.
+"""
+
+# TODO:
+#
+# * Let the picture stay centered when zooming in/out. (This seems to
+#   be harder than it, eh, seems; the signalling between
+#   ScrolledWindow and the adjustments is kind of puzzling. Probably
+#   need to get rid of ScrolledWindow and make something out of a
+#   table, two scrollbars and two adjustments or so.)
+# * Drag to scroll if the image is larger than displayed.
+# * Only draw visible image parts on expose-event.
+# * Bind mouse wheel to zoom in/out.
+# * Bind arrow keys.
+
+__all__ = ["ImageView"]
+
+if __name__ == "__main__":
+    import pygtk
+    pygtk.require("2.0")
 import gtk
-import gtk.gdk
-import math
+import gobject
 import gc
-from kofoto.gkofoto.environment import env
-from kofoto.common import calculate_downscaled_size
+import operator
+from kofoto.rectangle import Rectangle
+
+def _pixbuf_size(pixbuf):
+    return Rectangle(pixbuf.get_width(), pixbuf.get_height())
+
+def _safely_downscale_pixbuf(pixbuf, limit):
+    size = _pixbuf_size(pixbuf)
+    return _safely_scale_pixbuf(pixbuf, size.downscaled_to(limit))
+
+def _safely_rescale_pixbuf(pixbuf, limit):
+    size = _pixbuf_size(pixbuf)
+    return _safely_scale_pixbuf(pixbuf, size.rescaled_to(limit))
+
+def _safely_scale_pixbuf(pixbuf, size):
+    # The scaling with a factor not more than 30 is a work-around for
+    # a scaling bug in GTK+.
+
+    def scale_pixbuf(pixbuf, size):
+        if not Rectangle(1, 1).fits_within(size):
+            size = Rectangle(1, 1)
+        return pixbuf.scale_simple(
+            int(size.width), int(size.height), gtk.gdk.INTERP_BILINEAR)
+
+    psize = _pixbuf_size(pixbuf)
+    if psize.fits_within(size):
+        # Scale up.
+        factor = 1/30.0 # Somewhat arbitrary.
+        cmpfn = operator.lt
+    else:
+        # Scale down.
+        factor = 30 # Somewhat arbitrary.
+        cmpfn = operator.gt
+    while cmpfn(float(psize.max()) / size.max(), factor):
+        psize /= factor
+        pixbuf = scale_pixbuf(pixbuf, psize)
+    return scale_pixbuf(pixbuf, size)
 
 class ImageView(gtk.ScrolledWindow):
-    # TODO: Read from configuration file?
-    _INTERPOLATION_TYPE = gtk.gdk.INTERP_BILINEAR
-    # gtk.gdk.INTERP_HYPER is slower but gives better quality.
-    _MAX_IMAGE_SIZE = 2000
-    _MIN_IMAGE_SIZE = 10 # Work-around for bug in GTK. (pixbuf.scale_iter(1, 1) crashes.)
-    _MIN_ZOOM = -100
-    _MAX_ZOOM = 1
-    _ZOOMFACTOR = 1.2
+    """A reasonably quick image view widget supporting zooming."""
+
+    # Possible values of self._zoom_mode
+    _ZOOM_MODE_BEST_FIT = object()
+    _ZOOM_MODE_ACTUAL_SIZE = object()
+    _ZOOM_MODE_ZOOM = object()
 
     def __init__(self):
-        self._image = gtk.Image()
-        gtk.ScrolledWindow.__init__(self)
-        self._newImageLoaded = False
-        self.__loadedFileName = None
-        self.__pixBuf = None
-        self.__currentZoom = None
-        self.__wantedZoom = None
-        self.__fitToWindowMode = True
-        self.__previousWidgetWidth = 0
-        self.__previousWidgetHeight = 0
-
-        # Don't know why the EventBox is needed, but if it is removed,
-        # a size_allocate signal will trigger self.resizeEventHandler,
-        # which will resize the image, which will trigger
-        # size_allocate again, and so on.
-        eventBox = gtk.EventBox()
-        eventBox.add(self._image)
-
-        self.add_with_viewport(eventBox)
-        self.add_events(gtk.gdk.ALL_EVENTS_MASK)
-        self.connect_after("size-allocate", self.resizeEventHandler_cb)
-        self.connect("scroll-event", self.scrollEventHandler_cb)
-        self.connect("focus-in-event", self.focusInEventHandler_cb)
-        self.connect("focus-out-event", self.focusOutEventHandler_cb)
-
-    def focusInEventHandler_cb(self, widget, event):
-        pass
-
-    def focusOutEventHandler_cb(self, widget, event):
-        pass
-
-    def loadFile(self, fileName, reloadFile=True):
-        if (not reloadFile) and self.__loadedFileName == fileName:
-            return
-        self.clear()
-        env.debug("ImageView is loading image from file: " + fileName)
-        self.__pixBuf = env.mainwindow.getImagePreloader().getPixbuf(fileName)
-        if self.__pixBuf:
-            self.__loadedFileName = fileName
-        else:
-            dialog = gtk.MessageDialog(
-                type=gtk.MESSAGE_ERROR,
-                buttons=gtk.BUTTONS_OK,
-                message_format="Could not load image: %s" % fileName)
-            dialog.run()
-            dialog.destroy()
-            self.__pixBuf = env.unknownImageIconPixbuf
-            self.__loadedFileName = None
-        self._newImageLoaded = True
-        self._image.show()
-        self.fitToWindow_cb()
+        self.__gobject_init__() # TODO: Use gtk.ScrolledWindow.__init__ in PyGTK 2.8.
+
+        # Displayed pixbuf; None if not available.
+        self._displayed_pixbuf = None
+
+        # Zoom factor to use for one zoom step.
+        self._zoom_factor = 1.5
+
+        # Zoom size in pixels (a float, so that self._zoom_size *
+        # self._zoom_factor * 1/self._zoom_factor == self._zoom_size).
+        self._zoom_size = 0.0
+
+        # Zoom mode.
+        self._zoom_mode = ImageView._ZOOM_MODE_BEST_FIT
+
+        # Whether the widget should prescale resized images while
+        # waiting for the real thing.
+        self._prescale_mode = True
+
+        # Function to call when a new pixbuf size is wanted. None if
+        # ImageView.set_image has not yet been called.
+        self._request_pixbuf_func = None
+
+        # The original image as a Rectangle instance. None if not yet
+        # known.
+        self._available_size = None
+
+        # Remember requested image size (a Rectangle instance or None)
+        # to avoid unnecessary reloading.
+        self._previously_requested_image_size = None
+
+        # Pixbuf to display on errors.
+        self._error_pixbuf = self.render_icon(
+            gtk.STOCK_DIALOG_ERROR, gtk.ICON_SIZE_DIALOG, "kofoto")
+
+        # Subwidgets.
+        self._eventbox_widget = gtk.EventBox()
+        self._image_widget = gtk.DrawingArea()
+        ew = self._eventbox_widget
+        iw = self._image_widget
+        self.add_with_viewport(ew)
+        ew.add(iw)
+        iw.connect_after("realize", self._image_realize_cb)
+        iw.connect_after("unrealize", self._image_unrealize_cb)
+        iw.connect_after("size-allocate", self._image_after_size_allocate_cb)
+        iw.connect("expose-event", self._image_expose_event_cb)
+        self.set_policy(gtk.POLICY_NEVER, gtk.POLICY_NEVER)
 
     def clear(self):
-        self._image.hide()
-        self._image.set_from_file(None)
-        self.__pixBuf = None
-        self.__loadedFileName = None
-        gc.collect()
-        env.debug("ImageView is cleared.")
-
-    def reload(self):
-        self.loadFile(self.__loadedFileName)
-
-    def renderImage(self):
-        # TODO: Scaling should be asyncronous to avoid freezing the gtk-main loop
-        if self.__pixBuf == None:
-            # No image loaded
-            self._image.hide()
+        """Make the widget display nothing."""
+
+        assert self._is_realized()
+        self._available_size = None
+        self._displayed_pixbuf = None
+        self._image_widget.queue_draw()
+        gc.collect() # Help GTK to get rid of the old pixbuf.
+
+    def get_prescale_mode(self):
+        """Whether the widget should prescale a resized image."""
+
+        return self._prescale_mode
+
+    def get_wanted_image_size(self):
+        """Get the currently wanted image size.
+
+        Returns a tuple (width_limit, height_limit) or None. None
+        indicates that the full-size image is wanted. The size is the
+        same that will be passed to the load_pixbuf_func passed to
+        set_image.
+        """
+
+        return tuple(self._calculate_wanted_image_size())
+
+    def get_zoom_level(self):
+        """Get current zoom level."""
+
+        if self._displayed_pixbuf is None:
+            # Just return something.
+            return 1.0
+        else:
+            # We know that the proportions of self._displayed_pixbuf
+            # and the full-size image are the same since they are both
+            # set in set_from_pixbuf/set_error.
+            return (
+                float(self._displayed_pixbuf.get_width()) /
+                self._available_size.width)
+
+    def set_error(self):
+        """Indicate that loading of the image failed.
+
+        This method should be called by the pixbuf request function
+        passed to ImageView.set_image if there was an error loading
+        the pixbuf.
+        """
+
+        assert self._is_realized() and self._image_is_set()
+        self._available_size = _pixbuf_size(self._error_pixbuf)
+        self._displayed_pixbuf = self._error_pixbuf
+        self._image_widget.queue_draw()
+        gc.collect() # Help GTK to get rid of the old pixbuf.
+
+    def set_error_pixbuf(self, pixbuf):
+        """Set the pixbuf displayed on errors."""
+
+        self._error_pixbuf = pixbuf
+
+    def set_from_pixbuf(self, pixbuf, available_size):
+        """Set displayed pixbuf.
+
+        This method should be called by the pixbuf request function
+        passed to ImageView.set_image if the load was successful.
+
+        Arguments:
+
+        pixbuf           -- The pixbuf.
+        available_size   -- Tuple (width, height) of the full-size image.
+        """
+
+        assert self._is_realized() and self._image_is_set()
+        if self._available_size is not None:
+            if not Rectangle(*available_size).fits_within(self._available_size):
+                # The original has grown on disk, so we might want a
+                # larger pixbuf if available.
+                self._load_pixbuf()
+                return
+        self._available_size = Rectangle(*available_size)
+        self._create_displayed_pixbuf(pixbuf)
+        self._image_widget.queue_draw()
+        gc.collect() # Help GTK to get rid of the old pixbuf.
+
+    def set_image(self, load_pixbuf_func):
+        """Set image to display in the view.
+
+        This method indirectly sets the image to be displayed in the
+        widget. The argument function will be called (possibly
+        multiple times) to request a new pixbuf. Here are the
+        requirements on the function:
+
+        1. The function must accept one parameter: a tuple of width
+           limit and height limit in pixels, or None.
+        2. If the limit parameter is None, a full-size pixbuf should
+           be loaded.
+        3. The function must
+           a) call set_from_pixbuf with a the largest possible pixbuf
+              that fits within the limit; or
+           b) call set_error on failure.
+        4. The image proportions must be retained when scaling down
+           the image down to fit the limit.
+        5. The loaded pixbuf must not be larger than the original
+           image.
+
+        Arguments:
+
+        load_pixbuf_func -- The function.
+        """
+
+        self._request_pixbuf_func = load_pixbuf_func
+        self._available_size = None
+        if self._is_realized():
+            self._load_pixbuf()
+
+    def set_prescale_mode(self, mode):
+        """Set whether the widget should prescale a resized image."""
+
+        self._prescale_mode = mode
+
+    def set_zoom_factor(self, factor):
+        """Set the zoom factor."""
+
+        self._zoom_factor = factor
+
+    def zoom_in(self):
+        """Zoom in one step."""
+
+        assert self._is_realized() and self._image_is_set()
+        self._zoom_inout(self._zoom_factor)
+
+    def zoom_out(self):
+        """Zoom out one step."""
+
+        assert self._is_realized() and self._image_is_set()
+        self._zoom_inout(1 / self._zoom_factor)
+
+    def zoom_to(self, level):
+        """Zoom to a given zoom level.
+
+        Arguments:
+
+        level -- The zoom level (a number). 1.0 means the full-size
+                 image, 0.5 means a half-size image and so on.
+        """
+
+        assert self._is_realized() and self._image_is_set()
+        self._zoom_mode = ImageView._ZOOM_MODE_ZOOM
+        level = min(level, 1.0)
+        max_avail = max(
+            self._available_size.width, self._available_size.height)
+        self._zoom_size = level * max_avail
+        self._zoom_changed()
+
+    def zoom_to_actual(self):
+        """Zoom to actual (pixel-wise) image size."""
+
+        assert self._is_realized() and self._image_is_set()
+        self._zoom_mode = ImageView._ZOOM_MODE_ACTUAL_SIZE
+        self._zoom_changed()
+
+    def zoom_to_fit(self):
+        """Zoom image to fit the current widget size."""
+
+        assert self._is_realized() and self._image_is_set()
+        self._zoom_mode = ImageView._ZOOM_MODE_BEST_FIT
+        self.set_policy(gtk.POLICY_NEVER, gtk.POLICY_NEVER)
+        self._resize_image_widget()
+
+    def _calculate_best_fit_image_size(self):
+        allocation = self._image_widget.allocation
+        return Rectangle(allocation.width, allocation.height)
+
+    def _calculate_wanted_image_size(self):
+        if self._zoom_mode == ImageView._ZOOM_MODE_ACTUAL_SIZE:
+            return None
+        else:
+            if self._zoom_mode == ImageView._ZOOM_MODE_BEST_FIT:
+                size = self._calculate_best_fit_image_size()
+            else:
+                size = self._calculate_zoomed_image_size()
+            return size
+
+    def _calculate_zoomed_image_size(self):
+        zsize = int(round(self._zoom_size))
+        return Rectangle(zsize, zsize)
+
+    def _create_displayed_pixbuf(self, pixbuf):
+        size = self._calculate_wanted_image_size()
+        if self._zoom_mode == ImageView._ZOOM_MODE_ACTUAL_SIZE:
+            self._displayed_pixbuf = pixbuf
+        elif size == _pixbuf_size(pixbuf):
+            self._displayed_pixbuf = pixbuf
+        else:
+            self._displayed_pixbuf = _safely_downscale_pixbuf(pixbuf, size)
+        self._resize_image_widget()
+
+    def _displayed_pixbuf_is_current(self):
+        return self._available_size is not None
+
+    def _image_after_size_allocate_cb(self, widget, rect):
+        if not (self._is_realized() and self._image_is_set()):
             return
-        if self.__currentZoom == self.__wantedZoom and not self._newImageLoaded:
+        if self._zoom_mode != ImageView._ZOOM_MODE_BEST_FIT:
             return
-        if self.__wantedZoom == 0:
-            pixBufResized = self.__pixBuf
+        wanted_size = self._calculate_best_fit_image_size()
+        if self._displayed_pixbuf_is_current():
+            # We now know that self._available_size is available. 
+            # Also see comment in _load_pixbuf.
+            limited_wanted_size = wanted_size.downscaled_to(
+                self._available_size)
         else:
-            if self.__fitToWindowMode:
-                maxWidth, maxHeight = tuple(self.get_allocation())[2:4]
-                wantedWidth, wantedHeight = calculate_downscaled_size(
-                    self.__pixBuf.get_width(),
-                    self.__pixBuf.get_height(),
-                    maxWidth,
-                    maxHeight)
+            limited_wanted_size = wanted_size
+        psize = self._previously_requested_image_size
+        if psize is not None and limited_wanted_size != psize:
+            self._load_pixbuf()
+
+    def _image_expose_event_cb(self, widget, event):
+        if not self._displayed_pixbuf:
+            return
+        allocation = self._image_widget.allocation
+        pb = self._displayed_pixbuf
+        x = max(allocation.width - pb.get_width(), 0) / 2
+        y = max(allocation.height - pb.get_height(), 0) / 2
+        gcontext = self._image_widget.style.fg_gc[gtk.STATE_NORMAL]
+        dp = self._displayed_pixbuf
+        self._image_widget.window.draw_pixbuf(gcontext, dp, 0, 0, x, y, -1, -1)
+
+    def _image_realize_cb(self, widget):
+        if self._image_is_set():
+            self._load_pixbuf()
+
+    def _image_is_set(self):
+        return self._request_pixbuf_func is not None
+
+    def _image_unrealize_cb(self, widget):
+        self._displayed_pixbuf = None
+        gc.collect() # Help GTK to get rid of the old pixbuf.
+
+    def _is_realized(self):
+        return self.flags() & gtk.REALIZED
+
+    def _limit_zoom_size_to_available_size(self):
+        if self._displayed_pixbuf_is_current():
+            self._zoom_size = float(min(
+                self._zoom_size,
+                max(self._available_size.width, self._available_size.height)))
+
+    def _load_pixbuf(self):
+        size = self._calculate_wanted_image_size()
+        if self._zoom_mode != ImageView._ZOOM_MODE_ACTUAL_SIZE:
+            dp = self._displayed_pixbuf
+            if (self._prescale_mode and
+                self._displayed_pixbuf_is_current() and
+                size.fits_within(self._available_size)):
+                # Don't prescale to a pixbuf larger than the full-size
+                # image.
+                self._displayed_pixbuf = _safely_rescale_pixbuf(dp, size)
+                self._resize_image_widget()
+            if self._displayed_pixbuf_is_current():
+                # Remember/guess the actual size of the pixbuf to be
+                # loaded by _request_pixbuf_func. This is done to
+                # avoid _load_pixbuf being called when we expect that
+                # the currently displayed already is of the correct
+                # (full) size.
+                self._previously_requested_image_size = size.downscaled_to(
+                    self._available_size)
             else:
-                zoomMultiplicator = pow(self._ZOOMFACTOR, self.__wantedZoom)
-                wantedWidth = int(self.__pixBuf.get_width() * zoomMultiplicator)
-                wantedHeight = int(self.__pixBuf.get_height() * zoomMultiplicator)
-            if min(wantedWidth, wantedHeight) < self._MIN_IMAGE_SIZE:
-                # Too small image size
-                return
-            if max(wantedWidth, wantedHeight) > self._MAX_IMAGE_SIZE:
-                # Too large image size
-                return
-            pixBufResized = env.mainwindow.getImagePreloader().getPixbuf(
-                self.__loadedFileName,
-                wantedWidth,
-                wantedHeight)
-            if not pixBufResized:
-                pixBufResized = env.unknownImageIconPixbuf
-        pixMap, mask = pixBufResized.render_pixmap_and_mask()
-        self._image.set_from_pixmap(pixMap, mask)
-        self._newImageLoaded = False
-        self.__currentZoom = self.__wantedZoom
-        gc.collect()
-
-    def resizeEventHandler_cb(self, widget, gdkEvent):
-        if self.__fitToWindowMode:
-            _, _, width, height = self.get_allocation()
-            if height != self.__previousWidgetHeight or width != self.__previousWidgetWidth:
-                self.fitToWindow_cb()
-        return False
-
-    def fitToWindow_cb(self, *unused):
-        self.__fitToWindowMode = True
-        self.set_policy(gtk.POLICY_NEVER, gtk.POLICY_NEVER)
-        _, _, widgetWidth, widgetHeight = self.get_allocation()
-        if self.__pixBuf != None:
-            self.__previousWidgetWidth = widgetWidth
-            self.__previousWidgetHeight = widgetHeight
-            a = min(float(widgetWidth) / self.__pixBuf.get_width(),
-                    float(widgetHeight) / self.__pixBuf.get_height())
-            self.__wantedZoom = self._log(self._ZOOMFACTOR, a)
-            self.__wantedZoom = min(self.__wantedZoom, 0)
-            self.__wantedZoom = max(self.__wantedZoom, self._MIN_ZOOM)
-            self.renderImage()
-
-    def getAvailableSpace(self):
-        return tuple(self.get_allocation())[2:4]
-
-    def _log(self, base, value):
-        return math.log(value) / math.log(base)
-
-    def zoomIn_cb(self, *unused):
-        self.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
-        self.__fitToWindowMode = False
-        if self.__wantedZoom <= self._MAX_ZOOM:
-            self.__wantedZoom = math.floor(self.__wantedZoom + 1)
-            self.renderImage()
+                self._previously_requested_image_size = size
+        self._request_pixbuf_func(size)
 
-    def zoomOut_cb(self, *unused):
-        self.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
-        self.__fitToWindowMode = False
-        if self.__wantedZoom >= self._MIN_ZOOM:
-            self.__wantedZoom = math.ceil(self.__wantedZoom - 1)
-            self.renderImage()
+    def _resize_image_widget(self):
+        # The signal size-allocate is emitted when set_size_request is
+        # called.
+        if self._zoom_mode == ImageView._ZOOM_MODE_BEST_FIT:
+            self._image_widget.set_size_request(-1, -1)
+        else:
+            size = _pixbuf_size(self._displayed_pixbuf)
+            self._image_widget.set_size_request(size.width, size.height)
 
-    def zoom100_cb(self, *unused):
+    def _set_zoom_size_from_widget_allocation(self):
+        allocation = self._image_widget.allocation
+        self._zoom_size = float(max(allocation.width, allocation.height))
+
+    def _zoom_changed(self):
         self.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
-        self.__fitToWindowMode = False
-        self.__wantedZoom = 0
-        self.renderImage()
-
-    def scrollEventHandler_cb(self, widget, gdkEvent):
-        if gdkEvent.type == gtk.gdk.SCROLL:
-            if gdkEvent.direction == gtk.gdk.SCROLL_UP:
-                self.zoomOut_cb()
-            elif gdkEvent.direction == gtk.gdk.SCROLL_DOWN:
-                self.zoomIn_cb()
-            return True
-        else:
-            return False
+        self._load_pixbuf()
+
+    def _zoom_inout(self, factor):
+        if self._zoom_mode != ImageView._ZOOM_MODE_ZOOM:
+            self._zoom_mode = ImageView._ZOOM_MODE_ZOOM
+            self._set_zoom_size_from_widget_allocation()
+        # The two calls to _limit_zoom_size_to_available_size below
+        # make the zooming feel right in the following two cases:
+        #
+        # 1. Both cases: Enter zoom mode on a large image with a
+        #    sufficiently large zoom size.
+        # 2. Both cases: Change to an image that is smaller than the
+        #    current zoom size.
+        # 3. Case 1: Zoom in and then zoom out. Case 2: Zoom out.
+        self._limit_zoom_size_to_available_size()
+        self._zoom_size *= factor
+        self._limit_zoom_size_to_available_size()
+        self._zoom_changed()
+
+gobject.type_register(ImageView) # TODO: Not needed in PyGTK 2.8.
+
+######################################################################
+
+if __name__ == "__main__":
+    from kofoto.gkofoto.cachingpixbufloader import CachingPixbufLoader
+    import sys
+
+    caching_pixbuf_loader = CachingPixbufLoader()
+
+    class State:
+        def __init__(self):
+            self._latest_handle = None
+            self._latest_key = None
+            self._current_pic_index = -1
+            self._image_paths = sys.argv[1:]
+
+        def get_image_async_cb(self, size):
+            path = self._image_paths[self._current_pic_index]
+            if self._latest_handle is not None:
+                caching_pixbuf_loader.cancel_load(self._latest_handle)
+                if path == self._latest_key[0]:
+                    caching_pixbuf_loader.unload(*self._latest_key)
+            self._latest_handle = caching_pixbuf_loader.load(
+                path,
+                size,
+                imageview.set_from_pixbuf,
+                imageview.set_error)
+            self._latest_key = (path, size)
+
+        def next_image(self, *unused):
+            self._current_pic_index = \
+                (self._current_pic_index + 1) % len(self._image_paths)
+            imageview.set_image(self.get_image_async_cb)
+
+    def callback_wrapper(fn):
+        def _f(*unused):
+            fn()
+        return _f
+
+    def toggle_prescale_mode(widget):
+        imageview.set_prescale_mode(widget.get_active())
+
+    state = State()
+
+    window = gtk.Window()
+    window.set_default_size(300, 200)
+
+    imageview = ImageView()
+    window.add(imageview)
+
+    control_window = gtk.Window()
+    control_window.set_transient_for(window)
+
+    control_box = gtk.VBox()
+    control_window.add(control_box)
+
+    clear_button = gtk.Button(stock=gtk.STOCK_CLEAR)
+    control_box.add(clear_button)
+    clear_button.connect("clicked", callback_wrapper(imageview.clear))
+
+    ztf_button = gtk.Button(stock=gtk.STOCK_ZOOM_FIT)
+    control_box.add(ztf_button)
+    ztf_button.connect("clicked", callback_wrapper(imageview.zoom_to_fit))
+
+    za_button = gtk.Button(stock=gtk.STOCK_ZOOM_100)
+    control_box.add(za_button)
+    za_button.connect("clicked", callback_wrapper(imageview.zoom_to_actual))
+
+    zi_button = gtk.Button(stock=gtk.STOCK_ZOOM_IN)
+    control_box.add(zi_button)
+    zi_button.connect("clicked", callback_wrapper(imageview.zoom_in))
+
+    zo_button = gtk.Button(stock=gtk.STOCK_ZOOM_OUT)
+    control_box.add(zo_button)
+    zo_button.connect("clicked", callback_wrapper(imageview.zoom_out))
+
+    next_button = gtk.Button(stock=gtk.STOCK_GO_FORWARD)
+    control_box.add(next_button)
+    next_button.connect("clicked", state.next_image)
+
+    prescale_checkbutton = gtk.CheckButton("Prescale mode")
+    prescale_checkbutton.set_active(True)
+    control_box.add(prescale_checkbutton)
+    prescale_checkbutton.connect("toggled", toggle_prescale_mode)
+
+    window.show_all()
+    window.connect("destroy", gtk.main_quit)
+
+    control_window.show_all()
+
+    state.next_image()
+    gtk.main()
index 3ab6ea8..60bab40 100644 (file)
@@ -15,7 +15,6 @@ from kofoto.gkofoto.registerimagesdialog import RegisterImagesDialog
 from kofoto.gkofoto.handleimagesdialog import HandleImagesDialog
 from kofoto.gkofoto.generatehtmldialog import GenerateHTMLDialog
 from kofoto.gkofoto.persistentstate import PersistentState
-from kofoto.gkofoto.imagepreloader import ImagePreloader
 
 class MainWindow(gtk.Window):
     def __init__(self):
@@ -26,7 +25,6 @@ class MainWindow(gtk.Window):
         self.__currentObjectCollection = None
         self._currentView = None
         self.__persistentState = PersistentState(env)
-        self.__imagePreloader = ImagePreloader(env.debug)
         self.__sourceEntry = env.widgets["sourceEntry"]
         self.__filterEntry = env.widgets["filterEntry"]
         self.__filterEntry.set_text(self.__persistentState.filterText)
@@ -158,9 +156,6 @@ class MainWindow(gtk.Window):
         image.show()
         return image
 
-    def getImagePreloader(self):
-        return self.__imagePreloader
-
     def _viewChanged(self):
         for hiddenView in self._hiddenViews:
             hiddenView.hide()
index aec466d..a8eac58 100644 (file)
@@ -510,7 +510,6 @@ class ObjectCollection(object):
         dialog.runMergeImages(selectedObjects)
 
     def rotateImage(self, unused, angle):
-        env.mainwindow.getImagePreloader().clearCache()
         for (rowNr, obj) in self.__objectSelection.getMap().items():
             if not obj.isAlbum():
                 imageversion = obj.getPrimaryVersion()
@@ -518,6 +517,7 @@ class ObjectCollection(object):
                     # Image has no versions. Skip it for now.
                     continue
                 location = imageversion.getLocation()
+                env.pixbufLoader.unload_all(location)
                 if angle == 90:
                     commandString = env.rotateRightCommand
                 else:
@@ -528,7 +528,6 @@ class ObjectCollection(object):
                     imageversion.contentChanged()
                     model = self.getUnsortedModel()
                     self.__loadThumbnail(model, model.get_iter(rowNr))
-                    env.mainwindow.getImagePreloader().clearCache()
                 else:
                     dialog = gtk.MessageDialog(
                         type=gtk.MESSAGE_ERROR,
index 6a11f62..892b06e 100644 (file)
@@ -6,6 +6,11 @@ from kofoto.gkofoto.imageview import ImageView
 from kofoto.gkofoto.objectcollectionview import ObjectCollectionView
 from kofoto.gkofoto.imageversionslist import ImageVersionsList
 
+def _make_callback_wrapper(fn):
+    def f(*unused):
+        fn()
+    return f
+
 class SingleObjectView(ObjectCollectionView, gtk.HPaned):
 
 ###############################################################################
@@ -22,7 +27,7 @@ class SingleObjectView(ObjectCollectionView, gtk.HPaned):
         self.__imageVersionsFrame = gtk.Frame("Image versions")
         self.__imageVersionsFrame.set_size_request(162, -1)
         self.__imageVersionsWindow = gtk.ScrolledWindow()
-        self.__imageVersionsList = ImageVersionsList(self, self.__imageView)
+        self.__imageVersionsList = ImageVersionsList(self)
         self.__imageVersionsFrame.add(self.__imageVersionsList)
         self.pack2(self.__imageVersionsFrame, resize=False)
         self.show_all()
@@ -30,18 +35,29 @@ class SingleObjectView(ObjectCollectionView, gtk.HPaned):
         env.widgets["menubarNextImage"].connect("activate", self._goto, 1)
         env.widgets["previousButton"].connect("clicked", self._goto, -1)
         env.widgets["menubarPreviousImage"].connect("activate", self._goto, -1)
-        env.widgets["zoomToFit"].connect("clicked", self.__imageView.fitToWindow_cb)
-        env.widgets["menubarZoomToFit"].connect("activate", self.__imageView.fitToWindow_cb)
-        env.widgets["zoom100"].connect("clicked", self.__imageView.zoom100_cb)
-        env.widgets["menubarActualSize"].connect("activate", self.__imageView.zoom100_cb)
-        env.widgets["zoomIn"].connect("clicked", self.__imageView.zoomIn_cb)
-        env.widgets["menubarZoomIn"].connect("activate", self.__imageView.zoomIn_cb)
-        env.widgets["zoomOut"].connect("clicked", self.__imageView.zoomOut_cb)
-        env.widgets["menubarZoomOut"].connect("activate", self.__imageView.zoomOut_cb)
+        env.widgets["zoomToFit"].connect(
+            "clicked", _make_callback_wrapper(self.__imageView.zoom_to_fit))
+        env.widgets["menubarZoomToFit"].connect(
+            "activate", _make_callback_wrapper(self.__imageView.zoom_to_fit))
+        env.widgets["zoom100"].connect(
+            "clicked", _make_callback_wrapper(self.__imageView.zoom_to_actual))
+        env.widgets["menubarActualSize"].connect(
+            "activate", _make_callback_wrapper(self.__imageView.zoom_to_actual))
+        env.widgets["zoomIn"].connect(
+            "clicked", _make_callback_wrapper(self.__imageView.zoom_in))
+        env.widgets["menubarZoomIn"].connect(
+            "activate", _make_callback_wrapper(self.__imageView.zoom_in))
+        env.widgets["zoomOut"].connect(
+            "clicked", _make_callback_wrapper(self.__imageView.zoom_out))
+        env.widgets["menubarZoomOut"].connect(
+            "activate", _make_callback_wrapper(self.__imageView.zoom_out))
         env.widgets["menubarViewDetailsPane"].set_sensitive(True)
         self.__loadedObject = False
         self.__selectionLocked = False
         self.__selectedRowNr = None
+        self.__currentImageLocation = None
+        self.__latestLoadPixbufHandle = None
+        self.__latestPixbufLoadKey = None
 
     def showDetailsPane(self):
         self.__imageVersionsFrame.show()
@@ -100,15 +116,33 @@ class SingleObjectView(ObjectCollectionView, gtk.HPaned):
     def __loadObject(self, obj):
         self.__imageVersionsList.clear()
         if obj == None:
-            filename = env.unknownImageIconFileName
+            location = env.unknownImageIconFileName
         elif obj.isAlbum():
-            filename = env.albumIconFileName
+            location = env.albumIconFileName
         elif obj.getPrimaryVersion():
-            filename = obj.getPrimaryVersion().getLocation()
+            location = obj.getPrimaryVersion().getLocation()
             self.__imageVersionsList.loadImage(obj)
         else:
-            filename = env.unknownImageIconFileName
-        self.__imageView.loadFile(filename)
+            location = env.unknownImageIconFileName
+        self._loadImageAtLocation(location)
+
+    def _loadImageAtLocation(self, location):
+        if location == self.__currentImageLocation:
+            return
+        self.__currentImageLocation = location
+        self.__imageView.set_image(self.__loadPixbuf_cb)
+
+    def __loadPixbuf_cb(self, size_limit):
+        if self.__latestLoadPixbufHandle is not None:
+            env.pixbufLoader.cancel_load(self.__latestLoadPixbufHandle)
+            if self.__currentImageLocation == self.__latestPixbufLoadKey[0]:
+                env.pixbufLoader.unload(*self.__latestPixbufLoadKey)
+        self.__latestLoadPixbufHandle = env.pixbufLoader.load(
+            self.__currentImageLocation,
+            size_limit,
+            self.__imageView.set_from_pixbuf,
+            self.__imageView.set_error)
+        self.__latestPixbufLoadKey = (self.__currentImageLocation, size_limit)
 
     def _reloadSingleObjectView(self):
         self.reload()
@@ -161,7 +195,6 @@ class SingleObjectView(ObjectCollectionView, gtk.HPaned):
     def _freezeHelper(self):
         env.enter("SingleObjectView.freezeHelper()")
         self._clearAllConnections()
-        self.__imageView.clear()
         self._objectCollection.removeInsertedRowCallback(self._modelUpdated)
         env.exit("SingleObjectView.freezeHelper()")
 
@@ -186,14 +219,9 @@ class SingleObjectView(ObjectCollectionView, gtk.HPaned):
     def _preloadImages(self):
         objectSelection = self._objectCollection.getObjectSelection()
         filenames = objectSelection.getImageFilenamesToPreload()
-        maxWidth, maxHeight = self.__imageView.getAvailableSpace()
-
-        # Work-around for bug in GTK. (pixbuf.scale_iter(1, 1) crashes.)
-        if maxWidth < 10 and maxHeight < 10:
-            return
-
-        env.mainwindow.getImagePreloader().preloadImages(
-            filenames, maxWidth, maxHeight)
+        size_limit = self.__imageView.get_wanted_image_size()
+        for filename in filenames:
+            env.pixbufLoader.preload(filename, size_limit)
 
     def _hasFocus(self):
         return True