diff options
Diffstat (limited to 'trunk/dimbola/grid.py')
-rw-r--r-- | trunk/dimbola/grid.py | 464 |
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() + |