summaryrefslogtreecommitdiff
path: root/trunk/dimbola/grid.py
diff options
context:
space:
mode:
Diffstat (limited to 'trunk/dimbola/grid.py')
-rw-r--r--trunk/dimbola/grid.py464
1 files changed, 464 insertions, 0 deletions
diff --git a/trunk/dimbola/grid.py b/trunk/dimbola/grid.py
new file mode 100644
index 0000000..49916af
--- /dev/null
+++ b/trunk/dimbola/grid.py
@@ -0,0 +1,464 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+# This is necessary for running under Python 2.5, which we need to
+# do on Debian, for now.
+from __future__ import with_statement
+
+
+import gobject
+import gtk
+
+import dimbola
+
+
+class GridModel(gobject.GObject):
+
+ '''This is the MVC model of the thumbnail grid.
+
+ This class takes care of maintaining data about the grid: the current
+ list of photoids to be shown in the grid (photoids property), and the
+ list of photoids that are currently selected (selected property).
+ It also takes care of computing the vertical size in pixels of the
+ thumbnail grid (the whole grid, not just the visible part that gets
+ painted onto a gtk.DrawingArea).
+
+ The .selected property is a bit special. Its first element, if any,
+ is the focus for moving selection around with the keyboard.
+
+ '''
+
+ __gsignals__ = {
+ 'photoids-changed': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, []),
+ 'selection-changed': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, []),
+ }
+
+ def __init__(self):
+ gobject.GObject.__init__(self)
+ self._photoids = []
+ self._selected = []
+ self.thumbnails = dict()
+ self.angles = dict()
+ self.padding = 20
+ self.scale_value = None
+ self.widget_width = None
+ self.widget_height = None
+
+ def get_photoids(self):
+ return self._photoids
+ def set_photoids(self, photoids):
+ self._photoids = photoids
+ del self.selected[:]
+ self.thumbnails.clear()
+ self.angles.clear()
+ self.photoids_changed()
+ photoids = property(get_photoids, set_photoids)
+
+ def photoids_changed(self):
+ '''Emit the photoids-changed signal.'''
+ self.emit('photoids-changed')
+
+ def get_selected(self):
+ return self._selected
+ def set_selected(self, selected):
+ assert set(self._photoids).issuperset(set(selected))
+ self._selected = selected
+ self.selection_changed()
+ selected = property(get_selected, set_selected)
+
+ def selection_changed(self):
+ '''Emit the selection-changed signal.'''
+ self.emit('selection-changed')
+
+ def set_thumbnail(self, photoid, thumbnail):
+ '''Set the thumbnail for a photo.'''
+ assert photoid in self.photoids
+ self.thumbnails[photoid] = thumbnail
+ self.photoids_changed()
+
+ def set_angle(self, photoid, angle):
+ '''Set the angle for a photo.'''
+ assert photoid in self.photoids
+ self.angles[photoid] = angle
+ self.photoids_changed()
+
+ @property
+ def maxdim(self):
+ '''Maximum dimension of a thumbnail on the grid.'''
+ return min(int(self.scale_value), self.widget_width - self.padding)
+
+ @property
+ def distance(self):
+ '''Compute the distance between thumbnails in grid.
+
+ This includes padding between them.
+
+ '''
+ return self.maxdim + self.padding
+
+ @property
+ def maxcols(self):
+ '''Maximum number of columns.'''
+ return self.widget_width / self.distance
+
+ @property
+ def vertical_pixels(self):
+ '''Height of thumbnail grid (not just visible part) in pixels.
+
+ We get the dimension of the visible grid (the gtk.DrawingArea
+ widget) in pixels, and the size of the thumbnails also in pixels
+ from the slider. We also get the number of photos. We need to
+ compute the height of grid (not just visible part), in pixels.
+
+ '''
+
+ total_rows = (len(self.photoids) + self.maxcols - 1) / self.maxcols
+ return total_rows * self.distance
+
+ def thumbnail_pos(self, i):
+ '''Compute x and y for thumbnail of ith photo in grid.'''
+
+ maxcols = self.widget_width / self.distance
+
+ colno = i % self.maxcols
+ x = colno * self.distance
+
+ rowno = i / self.maxcols
+ y = rowno * self.distance
+
+ return x, y
+
+ def select_next(self):
+ '''Select next photo.'''
+ if self.selected:
+ i = self.photoids.index(self.selected[0])
+ self.selected = [self.photoids[min(i+1, len(self.photoids) - 1)]]
+ else:
+ self.selected = [self.photoids[0]]
+
+ def select_previous(self):
+ '''Select previous photo.'''
+ if self.selected:
+ i = self.photoids.index(self.selected[0])
+ self.selected = [self.photoids[max(i-1, 0)]]
+ else:
+ self.selected = [self.photoids[0]]
+
+
+class GridView(object): # pragma: no cover
+
+ '''This is the MVC view of the thumbnail grid.
+
+ This class takes care of drawing things on the grid.
+
+ '''
+
+ def __init__(self, model, widget, scrollbar):
+ self.model = model
+ self.widget = widget
+ self.scrollbar = scrollbar
+
+ # Set up the widget as a drag target.
+ self.widget.drag_dest_set(gtk.DEST_DEFAULT_ALL,
+ [(dimbola.TAGIDS_TYPE,
+ gtk.TARGET_SAME_APP, 0)],
+ gtk.gdk.ACTION_COPY)
+
+ # Which photoids are currently drawn as selected?
+ self.drawn_selected = list()
+
+ # Connect to the model's change signals so we automatically
+ # redraw the grid.
+ self.model.connect('photoids-changed', self.refresh_thumbnails)
+ self.model.connect('selection-changed', self.refresh_selection)
+
+ def resize_scrollbar(self):
+ '''Change the scrollbar's adjustment so it matches the model.'''
+ adj = self.scrollbar.get_adjustment()
+
+ lower = 0
+ upper = max(0, self.model.vertical_pixels - self.model.widget_height)
+ step = self.model.distance
+ page = self.model.widget_height
+
+ adj.set_all(lower=lower,
+ upper=upper,
+ step_increment=step,
+ page_increment=page,
+ page_size=page)
+
+ def coords_to_photoid(self, x, y):
+ '''Convert from widget's x,y to photoid.'''
+ y += int(self.scrollbar.get_value())
+ for i, photoid in enumerate(self.model.photoids):
+ x1, y1 = self.model.thumbnail_pos(i)
+ if (x >= x1 and x < x1 + self.model.distance and
+ y >= y1 and y < y1 + self.model.distance):
+ return photoid
+ return None
+
+ def refresh_selection(self, *args):
+ '''Update the selection on screen.'''
+ for photoid in set(self.drawn_selected + self.model.selected):
+ self.draw_thumbnail(photoid)
+ self.drawn_selected = self.model.selected[:]
+
+ def refresh_thumbnails(self, *args):
+ '''Update all thumbnails on screen.'''
+ if self.widget.window:
+ # We only do this if we're mapped and can draw.
+ self.widget.window.clear()
+ for photoid in self.model.photoids:
+ self.draw_thumbnail(photoid)
+ self.draw_focus_indicator()
+
+ def draw_focus_indicator(self):
+ '''Draw a visual focus indicator, if we have focus.'''
+
+ if self.widget.flags() & gtk.HAS_FOCUS:
+ width, height = self.widget.window.get_size()
+ self.widget.get_style().paint_focus(self.widget.window,
+ self.widget.state,
+ None,
+ None,
+ None,
+ 0, 0,
+ width, height)
+
+ def highlight_thumbnail(self, photoid):
+ '''Drag thumbnail with a drag destination highlight.'''
+ self.draw_thumbnail(photoid, highlight=True)
+
+ def draw_thumbnail(self, photoid, highlight=False):
+ '''Draw thumbnail onto grid view.'''
+
+ thumb = self.model.thumbnails.get(photoid)
+ if not thumb:
+ # We don't have the thumbnail yet. Can't draw it.
+ return
+
+ w = self.widget.window
+
+ style = self.widget.get_style()
+ if highlight:
+ bg = style.bg_gc[gtk.STATE_PRELIGHT]
+ fg = style.fg_gc[gtk.STATE_PRELIGHT]
+ else:
+ bg = style.bg_gc[gtk.STATE_NORMAL]
+ fg = style.fg_gc[gtk.STATE_NORMAL]
+
+ thumb = dimbola.scale_pixbuf(thumb, self.model.maxdim,
+ self.model.maxdim)
+ thumb = dimbola.rotate_pixbuf(thumb, self.model.angles.get(photoid, 0))
+ i = self.model.photoids.index(photoid)
+ x, y = self.model.thumbnail_pos(i)
+
+ y0 = int(self.scrollbar.get_value())
+ if y + self.model.distance < y0:
+ return
+ if y >= y0 + self.model.widget_height:
+ return
+
+ if photoid in self.model.selected:
+ gc = style.bg_gc[gtk.STATE_SELECTED]
+ else:
+ gc = bg
+ w.draw_rectangle(gc, True, x, y - y0, self.model.distance,
+ self.model.distance)
+
+ xdelta = (self.model.distance - thumb.get_width()) / 2
+ ydelta = (self.model.distance - thumb.get_height()) / 2
+ w.draw_pixbuf(fg, thumb, 0, 0, x + xdelta, y + ydelta - y0)
+ if highlight:
+ w.draw_rectangle(fg, False, x, y - y0, self.model.distance - 1,
+ self.model.distance - 1)
+
+
+class Grid(object): # pragma: no cover
+
+ '''This is the MVC controller of the thumbnail grid.
+
+ This class takes care of responding to events and signals related
+ to the grid. The rest of the world will interface with the grid
+ via this class.
+
+ '''
+
+ def __init__(self, mwc):
+ mwc.connect('setup-widgets', self.init)
+ mwc.connect('photo-meta-changed', self.on_photo_meta_changed)
+
+ def init(self, mwc):
+ '''Initialize this object after mwc's setup-widgets signal emitted.'''
+
+ self.mwc = mwc
+
+ self.model = GridModel()
+
+ self.box = mwc.widgets['grid_vbox']
+ drawingarea = mwc.widgets['thumbnail_drawingarea']
+ scrollbar = mwc.widgets['thumbnail_vscrollbar']
+ self.view = GridView(self.model, drawingarea, scrollbar)
+
+ self.scale = mwc.widgets['thumbnail_scale']
+ self.scale.set_range(50, 300)
+ self.scale.set_increments(10, 25)
+ self.scale.set_value(200)
+ self.model.scale_value = self.scale.get_value()
+
+ self.drag_dest = None
+
+ def on_photo_meta_changed(self, mwc, photoid):
+ with mwc.db:
+ a, b, c, rotate = mwc.db.get_basic_photo_metadata(photoid)
+ thumbnail = mwc.db.get_thumbnail(photoid)
+ self.model.set_angle(photoid, rotate)
+
+ def on_thumbnail_drawingarea_configure_event(self, widget, event):
+ self.model.widget_width = event.width
+ self.model.widget_height = event.height
+
+ def on_thumbnail_drawingarea_expose_event(self, *args):
+ if self.model.widget_height is not None:
+ self.view.refresh_thumbnails()
+ self.view.resize_scrollbar()
+
+ def on_thumbnail_scale_value_changed(self, *args):
+ self.model.scale_value = self.scale.get_value()
+ if self.model.widget_height is not None:
+ self.view.refresh_thumbnails()
+ self.view.resize_scrollbar()
+
+ def on_thumbnail_vscrollbar_value_changed(self, vscrollbar):
+ self.view.refresh_thumbnails()
+
+ def on_thumbnail_drawingarea_scroll_event(self, widget, event):
+ adj = self.view.scrollbar.get_adjustment()
+ value = adj.get_value()
+ step = adj.get_step_increment()
+ if event.direction == gtk.gdk.SCROLL_UP:
+ value = max(0, value - step)
+ else:
+ value = min(adj.get_upper(), value + step)
+ adj.set_value(value)
+
+ def on_thumbnail_drawingarea_button_press_event(self, widget, event):
+ '''Let user change thumbnail selection with mouse.'''
+
+ widget.grab_focus()
+
+ if event.button != 1:
+ return False
+
+ shift = (event.state & gtk.gdk.SHIFT_MASK) == gtk.gdk.SHIFT_MASK
+ ctrl = (event.state & gtk.gdk.CONTROL_MASK) == gtk.gdk.CONTROL_MASK
+ photoid = self.view.coords_to_photoid(event.x, event.y)
+ photoids = self.model.photoids
+ selected = self.model.selected
+
+ if event.type == gtk.gdk._2BUTTON_PRESS:
+ self.mwc.widgets['view_photo_menuitem'].set_active(True)
+ return False
+
+ if shift and photoid is not None:
+ # Extend current selection by selecting everything from oldest
+ # selection to the current one, inclusive, and only those.
+ if selected:
+ del selected[1:]
+ index0 = photoids.index(selected[0])
+ index = photoids.index(photoid)
+ if index < index0:
+ for i in range(index, index0):
+ selected.append(photoids[i])
+ else:
+ for i in range(index0 + 1, index + 1):
+ selected.append(photoids[i])
+ else:
+ # No current selection, just select current.
+ selected.append(photoid)
+ elif ctrl and photoid is not None:
+ # Add or remove current photo to selection.
+ if photoid in selected:
+ selected.remove(photoid)
+ else:
+ selected.append(photoid)
+ elif not shift and not ctrl:
+ # Just select current one.
+ del selected[:]
+ if photoid is not None:
+ selected.append(photoid)
+
+ self.model.selected = selected
+
+ def on_thumbnail_drawingarea_drag_leave(self, w, dc, timestamp):
+ if self.drag_dest is not None:
+ self.view.draw_thumbnail(self.drag_dest)
+ self.drag_dest = None
+
+ def on_thumbnail_drawingarea_drag_motion(self, w, dc, x, y, timestamp):
+ if self.drag_dest is not None:
+ self.view.draw_thumbnail(self.drag_dest)
+ self.drag_dest = None
+ self.drag_dest = self.view.coords_to_photoid(x, y)
+ if self.drag_dest is None:
+ return False
+ else:
+ dc.drag_status(gtk.gdk.ACTION_COPY, timestamp)
+ self.view.highlight_thumbnail(self.drag_dest)
+ return True
+
+ def on_thumbnail_drawingarea_drag_data_received(self, *args):
+ w, dc, x, y, data, info, timestamp = args
+ photoid = self.view.coords_to_photoid(x, y)
+ if photoid is not None:
+ tagids = dimbola.decode_dnd_tagids(data.data)
+ with self.mwc.db:
+ old_tagids = set(self.mwc.db.get_tagids(photoid))
+ for tagid in tagids:
+ if tagid not in old_tagids:
+ self.mwc.db.add_tagid(photoid, tagid)
+ dc.finish(True, False, timestamp)
+ self.model.selection_changed()
+
+ def request_rating(self, stars):
+ self.mwc.emit('photo-rating-requested', stars)
+
+ def on_thumbnail_drawingarea_key_press_event(self, widget, event):
+ if event.type == gtk.gdk.KEY_PRESS:
+ bindings = {
+ gtk.keysyms.Left: self.model.select_previous,
+ gtk.keysyms.Right: self.model.select_next,
+ '0': lambda *args: self.request_rating(0),
+ '1': lambda *args: self.request_rating(1),
+ '2': lambda *args: self.request_rating(2),
+ '3': lambda *args: self.request_rating(3),
+ '4': lambda *args: self.request_rating(4),
+ '5': lambda *args: self.request_rating(5),
+ }
+ if event.keyval in bindings:
+ bindings[event.keyval]()
+ return True
+ elif event.string in bindings:
+ bindings[event.string]()
+ return True
+ return False
+
+ def on_view_grid_menuitem_activate(self, radio):
+ if radio.get_active():
+ self.box.show()
+ else:
+ self.box.hide()
+