aboutsummaryrefslogtreecommitdiffstats
path: root/lvc/widgets/gtk/tableview.py
diff options
context:
space:
mode:
Diffstat (limited to 'lvc/widgets/gtk/tableview.py')
-rw-r--r--lvc/widgets/gtk/tableview.py1557
1 files changed, 1557 insertions, 0 deletions
diff --git a/lvc/widgets/gtk/tableview.py b/lvc/widgets/gtk/tableview.py
new file mode 100644
index 0000000..df66990
--- /dev/null
+++ b/lvc/widgets/gtk/tableview.py
@@ -0,0 +1,1557 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
+# Participatory Culture Foundation
+#
+# 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 2 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, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+"""tableview.py -- Wrapper for the GTKTreeView widget. It's used for the tab
+list and the item list (AKA almost all of the miro).
+"""
+
+import logging
+
+import itertools
+import gobject
+import gtk
+from collections import namedtuple
+
+# These are probably wrong, and are placeholders for now, until custom headers
+# are also implemented for GTK.
+CUSTOM_HEADER_HEIGHT = 25
+HEADER_HEIGHT = 25
+
+from lvc import signals
+from lvc.errors import (WidgetActionError, WidgetDomainError,
+ WidgetRangeError, WidgetNotReadyError)
+from lvc.widgets.tableselection import SelectionOwnerMixin
+from lvc.widgets.tablescroll import ScrollbarOwnerMixin
+import drawing
+import wrappermap
+from .base import Widget
+from .simple import Image
+from .layoutmanager import LayoutManager
+from .weakconnect import weak_connect
+from .tableviewcells import GTKCustomCellRenderer
+
+
+PathInfo = namedtuple('PathInfo', 'path column x y')
+Rect = namedtuple('Rect', 'x y width height')
+_album_view_gtkrc_installed = False
+
+def _install_album_view_gtkrc():
+ """Hack for styling GTKTreeView for the album view widget.
+
+ We do a couple things:
+ - Remove the focus ring
+ - Remove any separator space.
+
+ We do this so that we don't draw a box through the album view column for
+ selected rows.
+ """
+ global _album_view_gtkrc_installed
+ if _album_view_gtkrc_installed:
+ return
+ rc_string = ('style "album-view-style"\n'
+ '{ \n'
+ ' GtkTreeView::vertical-separator = 0\n'
+ ' GtkTreeView::horizontal-separator = 0\n'
+ ' GtkWidget::focus-line-width = 0 \n'
+ '}\n'
+ 'widget "*.miro-album-view" style "album-view-style"\n')
+ gtk.rc_parse_string(rc_string)
+ _album_view_gtkrc_installed = True
+
+def rect_contains_point(rect, x, y):
+ return ((rect.x <= x < rect.x + rect.width) and
+ (rect.y <= y < rect.y + rect.height))
+
+class TreeViewScrolling(object):
+ def __init__(self):
+ self.scrollbars = []
+ self.scroll_positions = None, None
+ self.restoring_scroll = None
+ self.connect('parent-set', self.on_parent_set)
+ self.scroller = None
+ # hack necessary because of our weird widget hierarchy (GTK doesn't deal
+ # well with the Scroller's widget not being the direct parent of the
+ # TableView's widget.)
+ self._coords_working = False
+
+ def scroll_range_changed(self):
+ """Faux-signal; this should all be integrated into
+ GTKScrollbarOwnerMixin, making this unnecessary.
+ """
+
+ @property
+ def manually_scrolled(self):
+ """Return whether the view has been scrolled explicitly by the user
+ since the last time it was set automatically.
+ """
+ auto_pos = self.scroll_positions[1]
+ if auto_pos is None:
+ # if we don't have any position yet, user can't have manually
+ # scrolled
+ return False
+ real_pos = self.scrollbars[1].get_value()
+ return abs(auto_pos - real_pos) > 5 # allowing some fuzziness
+
+ @property
+ def position_set(self):
+ """Return whether the scroll position has been set in any way."""
+ return any(x is not None for x in self.scroll_positions)
+
+ def on_parent_set(self, widget, old_parent):
+ """We have parent window now; we need to control its scrollbars."""
+ self.set_scroller(widget.get_parent())
+
+ def set_scroller(self, window):
+ """Take control of the scrollbars of window."""
+ if not isinstance(window, gtk.ScrolledWindow):
+ return
+ self.scroller = window
+ scrollbars = tuple(bar.get_adjustment()
+ for bar in (window.get_hscrollbar(), window.get_vscrollbar()))
+ self.scrollbars = scrollbars
+ for i, bar in enumerate(scrollbars):
+ weak_connect(bar, 'changed', self.on_scroll_range_changed, i)
+ if self.restoring_scroll:
+ self.set_scroll_position(self.restoring_scroll)
+
+ def on_scroll_range_changed(self, adjustment, bar):
+ """The scrollbar might have a range now. Set its initial position if
+ we haven't already.
+ """
+ self._coords_working = True
+ if self.restoring_scroll:
+ self.set_scroll_position(self.restoring_scroll)
+ # our wrapper handles the same thing for iters
+ self.scroll_range_changed()
+
+ def set_scroll_position(self, scroll_position):
+ """Restore the scrollbars to a remembered state."""
+ try:
+ self.scroll_positions = tuple(self._clip_pos(adj, x)
+ for adj, x in zip(self.scrollbars, scroll_position))
+ except WidgetActionError, error:
+ logging.debug("can't scroll yet: %s", error.reason)
+ # try again later
+ self.restoring_scroll = scroll_position
+ else:
+ for adj, pos in zip(self.scrollbars, self.scroll_positions):
+ adj.set_value(pos)
+ self.restoring_scroll = None
+
+ def _clip_pos(self, adj, pos):
+ lower = adj.get_lower()
+ upper = adj.get_upper() - adj.get_page_size()
+ # currently, StandardView gets an upper of 2.0 when it's not ready
+ # FIXME: don't count on that
+ if pos > upper and upper < 5:
+ raise WidgetRangeError("scrollable area", pos, lower, upper)
+ return min(max(pos, lower), upper)
+
+ def get_path_rect(self, path):
+ """Return the Rect for the given item, in tree coords."""
+ if not self._coords_working:
+ # part of solution to #17405; widget_to_tree_coords tends to return
+ # y=8 before the first scroll-range-changed signal. ugh.
+ raise WidgetNotReadyError('_coords_working')
+ rect = self.get_background_area(path, self.get_columns()[0])
+ x, y = self.widget_to_tree_coords(rect.x, rect.y)
+ return Rect(x, y, rect.width, rect.height)
+
+ @property
+ def _scrollbars(self):
+ if not self.scrollbars:
+ raise WidgetNotReadyError
+ return self.scrollbars
+
+ def scroll_ancestor(self, newly_selected, down):
+ # Try to figure out what just became selected. If multiple things
+ # somehow became selected, select the outermost one
+ if len(newly_selected) == 0:
+ raise WidgetActionError("need at an item to scroll to")
+ if down:
+ path_to_show = max(newly_selected)
+ else:
+ path_to_show = min(newly_selected)
+
+ if not self.scrollbars:
+ return
+ vadjustment = self.scrollbars[1]
+
+ rect = self.get_background_area(path_to_show, self.get_columns()[0])
+ _, top = self.translate_coordinates(self.scroller, 0, rect.y)
+ top += vadjustment.value
+ bottom = top + rect.height
+ if down:
+ if bottom > vadjustment.value + vadjustment.page_size:
+ bottom_value = min(bottom, vadjustment.upper)
+ vadjustment.set_value(bottom_value - vadjustment.page_size)
+ else:
+ if top < vadjustment.value:
+ vadjustment.set_value(max(vadjustment.lower, top))
+
+class MiroTreeView(gtk.TreeView, TreeViewScrolling):
+ """Extends the GTK TreeView widget to help implement TableView
+ https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
+ # Add a tiny bit of padding so that the user can drag feeds below
+ # the table, i.e. to the bottom row, as a top-level
+ PAD_BOTTOM = 3
+ def __init__(self):
+ gtk.TreeView.__init__(self)
+ TreeViewScrolling.__init__(self)
+ self.height_without_pad_bottom = -1
+ self.set_enable_search(False)
+ self.horizontal_separator = self.style_get_property("horizontal-separator")
+ self.expander_size = self.style_get_property("expander-size")
+ self.group_lines_enabled = False
+ self.group_line_color = (0, 0, 0)
+ self.group_line_width = 1
+
+ def do_size_request(self, req):
+ gtk.TreeView.do_size_request(self, req)
+ self.height_without_pad_bottom = req.height
+ req.height += self.PAD_BOTTOM
+
+ def do_move_cursor(self, step, count):
+ if step == gtk.MOVEMENT_VISUAL_POSITIONS:
+ # GTK is asking us to move left/right. Since our TableViews don't
+ # support this, return False to let the key press propagate. See
+ # #15646 for more info.
+ return False
+ if isinstance(self.get_parent(), gtk.ScrolledWindow):
+ # If our parent is a ScrolledWindow, let GTK take care of this
+ handled = gtk.TreeView.do_move_cursor(self, step, count)
+ return handled
+ else:
+ # Otherwise, we have to search up the widget tree for a
+ # ScrolledWindow to take care of it
+ selection = self.get_selection()
+ model, start_selection = selection.get_selected_rows()
+ gtk.TreeView.do_move_cursor(self, step, count)
+
+ model, end_selection = selection.get_selected_rows()
+ newly_selected = set(end_selection) - set(start_selection)
+ down = (count > 0)
+
+ try:
+ self.scroll_ancestor(newly_selected, down)
+ except WidgetActionError:
+ # not possible
+ return False
+ return True
+
+ def get_position_info(self, x, y):
+ """Wrapper for get_path_at_pos that converts the path_info to a named
+ tuple and handles rounding the coordinates.
+ """
+ path_info = self.get_path_at_pos(int(round(x)), int(round(y)))
+ if path_info:
+ return PathInfo(*path_info)
+
+gobject.type_register(MiroTreeView)
+
+class HotspotTracker(object):
+ """Handles tracking hotspots.
+ https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
+
+ def __init__(self, treeview, event):
+ self.treeview = treeview
+ self.treeview_wrapper = wrappermap.wrapper(treeview)
+ self.hit = False
+ self.button = event.button
+ path_info = treeview.get_position_info(event.x, event.y)
+ if path_info is None:
+ return
+ self.path, self.column, background_x, background_y = path_info
+ # We always pack 1 renderer for each column
+ gtk_renderer = self.column.get_cell_renderers()[0]
+ if not isinstance(gtk_renderer, GTKCustomCellRenderer):
+ return
+ self.renderer = wrappermap.wrapper(gtk_renderer)
+ self.attr_map = self.treeview_wrapper.attr_map_for_column[self.column]
+ if not rect_contains_point(self.calc_cell_area(), event.x, event.y):
+ # Mouse is in the padding around the actual cell area
+ return
+ self.update_position(event)
+ self.iter = treeview.get_model().get_iter(self.path)
+ self.name = self.calc_hotspot()
+ if self.name is not None:
+ self.hit = True
+
+ def is_for_context_menu(self):
+ return self.name == "#show-context-menu"
+
+ def calc_cell_area(self):
+ cell_area = self.treeview.get_cell_area(self.path, self.column)
+ xpad = self.renderer._renderer.props.xpad
+ ypad = self.renderer._renderer.props.ypad
+ cell_area.x += xpad
+ cell_area.y += ypad
+ cell_area.width -= xpad * 2
+ cell_area.height -= ypad * 2
+ return cell_area
+
+ def update_position(self, event):
+ self.x, self.y = int(event.x), int(event.y)
+
+ def calc_cell_state(self):
+ if self.treeview.get_selection().path_is_selected(self.path):
+ if self.treeview.flags() & gtk.HAS_FOCUS:
+ return gtk.STATE_SELECTED
+ else:
+ return gtk.STATE_ACTIVE
+ else:
+ return gtk.STATE_NORMAL
+
+ def calc_hotspot(self):
+ cell_area = self.calc_cell_area()
+ if rect_contains_point(cell_area, self.x, self.y):
+ model = self.treeview.get_model()
+ self.renderer.cell_data_func(self.column, self.renderer._renderer,
+ model, self.iter, self.attr_map)
+ style = drawing.DrawingStyle(self.treeview_wrapper,
+ use_base_color=True, state=self.calc_cell_state())
+ x = self.x - cell_area.x
+ y = self.y - cell_area.y
+ return self.renderer.hotspot_test(style,
+ self.treeview_wrapper.layout_manager,
+ x, y, cell_area.width, cell_area.height)
+ else:
+ return None
+
+ def update_hit(self):
+ if self.is_for_context_menu():
+ return # we always keep hit = True for this one
+ old_hit = self.hit
+ self.hit = (self.calc_hotspot() == self.name)
+ if self.hit != old_hit:
+ self.redraw_cell()
+
+ def redraw_cell(self):
+ # Check that the treeview is still around. We might have switched
+ # views in response to a hotspot being clicked.
+ if self.treeview.flags() & gtk.REALIZED:
+ cell_area = self.treeview.get_cell_area(self.path, self.column)
+ x, y = self.treeview.tree_to_widget_coords(cell_area.x,
+ cell_area.y)
+ self.treeview.queue_draw_area(x, y,
+ cell_area.width, cell_area.height)
+
+class TableColumn(signals.SignalEmitter):
+ """A single column of a TableView.
+
+ Signals:
+
+ clicked (table_column) -- The header for this column was clicked.
+ """
+ # GTK hard-codes 4px of padding for each column
+ FIXED_PADDING = 4
+ def __init__(self, title, renderer, header=None, **attrs):
+ # header widget not used yet in GTK (#15800)
+ signals.SignalEmitter.__init__(self)
+ self.create_signal('clicked')
+ self._column = gtk.TreeViewColumn(title, renderer._renderer)
+ self._column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
+ self._column.set_clickable(True)
+ self.attrs = attrs
+ renderer.setup_attributes(self._column, attrs)
+ self.renderer = renderer
+ weak_connect(self._column, 'clicked', self._header_clicked)
+ self.do_horizontal_padding = True
+
+ def set_right_aligned(self, right_aligned):
+ """Horizontal alignment of the header label."""
+ if right_aligned:
+ self._column.set_alignment(1.0)
+ else:
+ self._column.set_alignment(0.0)
+
+ def set_min_width(self, width):
+ self._column.props.min_width = width + TableColumn.FIXED_PADDING
+
+ def set_max_width(self, width):
+ self._column.props.max_width = width
+
+ def set_width(self, width):
+ self._column.set_fixed_width(width + TableColumn.FIXED_PADDING)
+
+ def get_width(self):
+ return self._column.get_width()
+
+ def _header_clicked(self, tablecolumn):
+ self.emit('clicked')
+
+ def set_resizable(self, resizable):
+ """Set if the user can resize the column."""
+ self._column.set_resizable(resizable)
+
+ def set_do_horizontal_padding(self, horizontal_padding):
+ self.do_horizontal_padding = False
+
+ def set_sort_indicator_visible(self, visible):
+ """Show/Hide the sort indicator for this column."""
+ self._column.set_sort_indicator(visible)
+
+ def get_sort_indicator_visible(self):
+ return self._column.get_sort_indicator()
+
+ def set_sort_order(self, ascending):
+ """Display a sort indicator on the column header. Ascending can be
+ either True or False which affects the direction of the indicator.
+ """
+ if ascending:
+ self._column.set_sort_order(gtk.SORT_ASCENDING)
+ else:
+ self._column.set_sort_order(gtk.SORT_DESCENDING)
+
+ def get_sort_order_ascending(self):
+ """Returns if the sort indicator is displaying that the sort is
+ ascending.
+ """
+ return self._column.get_sort_order() == gtk.SORT_ASCENDING
+
+class GTKSelectionOwnerMixin(SelectionOwnerMixin):
+ """GTK-specific methods for selection management.
+
+ This subclass should not define any behavior. Methods that cannot be
+ completed in this widget state should raise WidgetActionError.
+ """
+ def __init__(self):
+ SelectionOwnerMixin.__init__(self)
+ self.selection = self._widget.get_selection()
+ weak_connect(self.selection, 'changed', self.on_selection_changed)
+
+ def _set_allow_multiple_select(self, allow):
+ if allow:
+ mode = gtk.SELECTION_MULTIPLE
+ else:
+ mode = gtk.SELECTION_SINGLE
+ self.selection.set_mode(mode)
+
+ def _get_allow_multiple_select(self):
+ return self.selection.get_mode() == gtk.SELECTION_MULTIPLE
+
+ def _get_selected_iters(self):
+ iters = []
+ def collect(treemodel, path, iter_):
+ iters.append(iter_)
+ self.selection.selected_foreach(collect)
+ return iters
+
+ def _get_selected_iter(self):
+ model, iter_ = self.selection.get_selected()
+ return iter_
+
+ @property
+ def num_rows_selected(self):
+ return self.selection.count_selected_rows()
+
+ def _is_selected(self, iter_):
+ return self.selection.iter_is_selected(iter_)
+
+ def _select(self, iter_):
+ self.selection.select_iter(iter_)
+
+ def _unselect(self, iter_):
+ self.selection.unselect_iter(iter_)
+
+ def _unselect_all(self):
+ self.selection.unselect_all()
+
+ def _iter_to_string(self, iter_):
+ return self._model.get_string_from_iter(iter_)
+
+ def _iter_from_string(self, string):
+ try:
+ return self._model.get_iter_from_string(string)
+ except ValueError:
+ raise WidgetDomainError(
+ "model iters", string, "%s other iters" % len(self.model))
+
+ def select_path(self, path):
+ self.selection.select_path(path)
+
+ def _validate_iter(self, iter_):
+ if self.get_path(iter_) is None:
+ raise WidgetDomainError(
+ "model iters", iter_, "%s other iters" % len(self.model))
+ real_model = self._widget.get_model()
+ if not real_model:
+ raise WidgetActionError("no model")
+ elif real_model != self._model:
+ raise WidgetActionError("wrong model?")
+
+ def get_cursor(self):
+ """Return the path of the 'focused' item."""
+ path, column = self._widget.get_cursor()
+ return path
+
+ def set_cursor(self, path):
+ """Set the path of the 'focused' item."""
+ if path is None:
+ # XXX: is there a way to clear the cursor?
+ return
+ path_as_string = ':'.join(str(component) for component in path)
+ with self.preserving_selection(): # set_cursor() messes up the selection
+ self._widget.set_cursor(path_as_string)
+
+class DNDHandlerMixin(object):
+ """TableView row DnD.
+
+ Depends on arbitrary TableView methods; otherwise self-contained except:
+ on_button_press: may call start_drag
+ on_button_release: may unset drag_button_down
+ on_motion_notify: may call potential_drag_motion
+ """
+ def __init__(self):
+ self.drag_button_down = False
+ self.drag_data = {}
+ self.drag_source = self.drag_dest = None
+ self.drag_start_x, self.drag_start_y = None, None
+ self.wrapped_widget_connect('drag-data-get', self.on_drag_data_get)
+ self.wrapped_widget_connect('drag-end', self.on_drag_end)
+ self.wrapped_widget_connect('drag-motion', self.on_drag_motion)
+ self.wrapped_widget_connect('drag-leave', self.on_drag_leave)
+ self.wrapped_widget_connect('drag-drop', self.on_drag_drop)
+ self.wrapped_widget_connect('drag-data-received',
+ self.on_drag_data_received)
+ self.wrapped_widget_connect('unrealize', self.on_drag_unrealize)
+
+ def set_drag_source(self, drag_source):
+ self.drag_source = drag_source
+ # XXX: the following note no longer seems accurate:
+ # No need to call enable_model_drag_source() here, we handle it
+ # ourselves in on_motion_notify()
+
+ def set_drag_dest(self, drag_dest):
+ """Set the drop handler."""
+ self.drag_dest = drag_dest
+ if drag_dest is not None:
+ targets = self._gtk_target_list(drag_dest.allowed_types())
+ self._widget.enable_model_drag_dest(targets,
+ drag_dest.allowed_actions())
+ self._widget.drag_dest_set(0, targets,
+ drag_dest.allowed_actions())
+ else:
+ self._widget.unset_rows_drag_dest()
+ self._widget.drag_dest_unset()
+
+ def start_drag(self, treeview, event, path_info):
+ """Check whether the event is a drag event; return whether handled
+ here.
+ """
+ if event.state & (gtk.gdk.CONTROL_MASK | gtk.gdk.SHIFT_MASK):
+ return False
+ model, row_paths = treeview.get_selection().get_selected_rows()
+
+ if path_info.path not in row_paths:
+ # something outside the selection is being dragged.
+ # make it the new selection.
+ self.unselect_all(signal=False)
+ self.select_path(path_info.path)
+ row_paths = [path_info.path]
+ rows = self.model.get_rows(row_paths)
+ self.drag_data = rows and self.drag_source.begin_drag(self, rows)
+ self.drag_button_down = bool(self.drag_data)
+ if self.drag_button_down:
+ self.drag_start_x = int(event.x)
+ self.drag_start_y = int(event.y)
+
+ if len(row_paths) > 1 and path_info.path in row_paths:
+ # handle multiple selection. If the current row is already
+ # selected, stop propagating the signal. We will only change
+ # the selection if the user doesn't start a DnD operation.
+ # This makes it more natural for the user to drag a block of
+ # selected items.
+ renderer = path_info.column.get_cell_renderers()[0]
+ if (not self._x_coord_in_expander(treeview, path_info)
+ and not isinstance(renderer, GTKCheckboxCellRenderer)):
+ self.delaying_press = True
+ # grab keyboard focus since we handled the event
+ self.focus()
+ return True
+
+ def on_drag_data_get(self, treeview, context, selection, info, timestamp):
+ for typ, data in self.drag_data.items():
+ selection.set(typ, 8, repr(data))
+
+ def on_drag_end(self, treeview, context):
+ self.drag_data = {}
+
+ def find_type(self, drag_context):
+ return self._widget.drag_dest_find_target(drag_context,
+ self._widget.drag_dest_get_target_list())
+
+ def calc_positions(self, x, y):
+ """Given x and y coordinates, generate a list of drop positions to
+ try. The values are tuples in the form of (parent_path, position,
+ gtk_path, gtk_position), where parent_path and position is the
+ position to send to the Miro code, and gtk_path and gtk_position is an
+ equivalent position to send to the GTK code if the drag_dest validates
+ the drop.
+ """
+ model = self._model
+ try:
+ gtk_path, gtk_position = self._widget.get_dest_row_at_pos(x, y)
+ except TypeError:
+ # Below the last row
+ yield (None, len(model), None, None)
+ return
+
+ iter_ = model.get_iter(gtk_path)
+ if gtk_position in (gtk.TREE_VIEW_DROP_INTO_OR_BEFORE,
+ gtk.TREE_VIEW_DROP_INTO_OR_AFTER):
+ yield (iter_, -1, gtk_path, gtk_position)
+
+ if hasattr(model, 'iter_is_valid'):
+ # tablist has this; item list does not
+ assert model.iter_is_valid(iter_)
+ parent_iter = model.iter_parent(iter_)
+ position = gtk_path[-1]
+ if gtk_position in (gtk.TREE_VIEW_DROP_BEFORE,
+ gtk.TREE_VIEW_DROP_INTO_OR_BEFORE):
+ # gtk gave us a "before" position, no need to change it
+ yield (parent_iter, position, gtk_path, gtk.TREE_VIEW_DROP_BEFORE)
+ else:
+ # gtk gave us an "after" position, translate that to before the
+ # next row for miro.
+ if (self._widget.row_expanded(gtk_path) and
+ model.iter_has_child(iter_)):
+ child_path = gtk_path + (0,)
+ yield (iter_, 0, child_path, gtk.TREE_VIEW_DROP_BEFORE)
+ else:
+ yield (parent_iter, position+1, gtk_path,
+ gtk.TREE_VIEW_DROP_AFTER)
+
+ def on_drag_motion(self, treeview, drag_context, x, y, timestamp):
+ if not self.drag_dest:
+ return True
+ type = self.find_type(drag_context)
+ if type == "NONE":
+ drag_context.drag_status(0, timestamp)
+ return True
+ drop_action = 0
+ for pos_info in self.calc_positions(x, y):
+ drop_action = self.drag_dest.validate_drop(self, self.model, type,
+ drag_context.actions, pos_info[0], pos_info[1])
+ if isinstance(drop_action, (list, tuple)):
+ drop_action, iter = drop_action
+ path = self.model.get_path(iter)
+ pos = gtk.TREE_VIEW_DROP_INTO_OR_BEFORE
+ else:
+ path, pos = pos_info[2:4]
+
+ if drop_action:
+ self.set_drag_dest_row(path, pos)
+ break
+ else:
+ self.unset_drag_dest_row()
+ drag_context.drag_status(drop_action, timestamp)
+ return True
+
+ def set_drag_dest_row(self, path, position):
+ self._widget.set_drag_dest_row(path, position)
+
+ def unset_drag_dest_row(self):
+ self._widget.unset_drag_dest_row()
+
+ def on_drag_leave(self, treeview, drag_context, timestamp):
+ treeview.unset_drag_dest_row()
+
+ def on_drag_drop(self, treeview, drag_context, x, y, timestamp):
+ # prevent the default handler
+ treeview.emit_stop_by_name('drag-drop')
+ target = self.find_type(drag_context)
+ if target == "NONE":
+ return False
+ treeview.drag_get_data(drag_context, target, timestamp)
+ treeview.unset_drag_dest_row()
+
+ def on_drag_data_received(self,
+ treeview, drag_context, x, y, selection, info, timestamp):
+ # prevent the default handler
+ treeview.emit_stop_by_name('drag-data-received')
+ if not self.drag_dest:
+ return
+ type = self.find_type(drag_context)
+ if type == "NONE":
+ return
+ if selection.data is None:
+ return
+ drop_action = 0
+ for pos_info in self.calc_positions(x, y):
+ drop_action = self.drag_dest.validate_drop(self, self.model, type,
+ drag_context.actions, pos_info[0], pos_info[1])
+ if drop_action:
+ self.drag_dest.accept_drop(self, self.model, type,
+ drag_context.actions, pos_info[0], pos_info[1],
+ eval(selection.data))
+ return True
+ return False
+
+ def on_drag_unrealize(self, treeview):
+ self.drag_button_down = False
+
+ def potential_drag_motion(self, treeview, event):
+ """A motion event has occurred and did not hit a hotspot; start a drag
+ if applicable.
+ """
+ if (self.drag_data and self.drag_button_down and
+ treeview.drag_check_threshold(self.drag_start_x,
+ self.drag_start_y, int(event.x), int(event.y))):
+ self.delaying_press = False
+ treeview.drag_begin(self._gtk_target_list(self.drag_data.keys()),
+ self.drag_source.allowed_actions(), 1, event)
+
+ @staticmethod
+ def _gtk_target_list(types):
+ count = itertools.count()
+ return [(type, gtk.TARGET_SAME_APP, count.next()) for type in types]
+
+class HotspotTrackingMixin(object):
+ def __init__(self):
+ self.hotspot_tracker = None
+ self.create_signal('hotspot-clicked')
+ self._hotspot_callback_handles = []
+ self._connect_hotspot_signals()
+ self.wrapped_widget_connect('unrealize', self.on_hotspot_unrealize)
+
+ def _connect_hotspot_signals(self):
+ SIGNALS = {
+ 'row-inserted': self.on_row_inserted,
+ 'row-deleted': self.on_row_deleted,
+ 'row-changed': self.on_row_changed,
+ }
+ self._hotspot_callback_handles.extend(
+ weak_connect(self._model, signal, handler)
+ for signal, handler in SIGNALS.iteritems())
+
+ def _disconnect_hotspot_signals(self):
+ for handle in self._hotspot_callback_handles:
+ self._model.disconnect(handle)
+ self._hotspot_callback_handles = []
+
+ def on_row_inserted(self, model, path, iter_):
+ if self.hotspot_tracker:
+ self.hotspot_tracker.redraw_cell()
+ self.hotspot_tracker = None
+
+ def on_row_deleted(self, model, path):
+ if self.hotspot_tracker:
+ self.hotspot_tracker.redraw_cell()
+ self.hotspot_tracker = None
+
+ def on_row_changed(self, model, path, iter_):
+ if self.hotspot_tracker:
+ self.hotspot_tracker.update_hit()
+
+ def handle_hotspot_hit(self, treeview, event):
+ """Check whether the event is a hotspot event; return whether handled
+ here.
+ """
+ if self.hotspot_tracker:
+ return
+ hotspot_tracker = HotspotTracker(treeview, event)
+ if hotspot_tracker.hit:
+ self.hotspot_tracker = hotspot_tracker
+ hotspot_tracker.redraw_cell()
+ if hotspot_tracker.is_for_context_menu():
+ menu = self._popup_context_menu(self.hotspot_tracker.path, event)
+ if menu:
+ menu.connect('selection-done',
+ self._on_hotspot_context_menu_selection_done)
+ # grab keyboard focus since we handled the event
+ self.focus()
+ return True
+
+ def _on_hotspot_context_menu_selection_done(self, menu):
+ # context menu is closed, we won't get the button-release-event in
+ # this case, but we can unset hotspot tracker here.
+ if self.hotspot_tracker:
+ self.hotspot_tracker.redraw_cell()
+ self.hotspot_tracker = None
+
+ def on_hotspot_unrealize(self, treeview):
+ self.hotspot_tracker = None
+
+ def release_on_hotspot(self, event):
+ """A button_release occurred; return whether it has been handled as a
+ hotspot hit.
+ """
+ hotspot_tracker = self.hotspot_tracker
+ if hotspot_tracker and event.button == hotspot_tracker.button:
+ hotspot_tracker.update_position(event)
+ hotspot_tracker.update_hit()
+ if (hotspot_tracker.hit and
+ not hotspot_tracker.is_for_context_menu()):
+ self.emit('hotspot-clicked', hotspot_tracker.name,
+ hotspot_tracker.iter)
+ hotspot_tracker.redraw_cell()
+ self.hotspot_tracker = None
+ return True
+
+ def hotspot_model_changed(self):
+ """A bulk change has ended; reconnect signals and update hotspots."""
+ self._connect_hotspot_signals()
+ if self.hotspot_tracker:
+ self.hotspot_tracker.redraw_cell()
+ self.hotspot_tracker.update_hit()
+
+class ColumnOwnerMixin(object):
+ """Keeps track of the table's columns - including the list of columns, and
+ properties that we set for a table but need to apply to each column.
+
+ This manages:
+ columns
+ attr_map_for_column
+ gtk_column_to_wrapper
+ for use throughout tableview.
+ """
+ def __init__(self):
+ self._columns_draggable = False
+ self._renderer_xpad = self._renderer_ypad = 0
+ self.columns = []
+ self.attr_map_for_column = {}
+ self.gtk_column_to_wrapper = {}
+ self.create_signal('reallocate-columns') # not emitted on GTK
+
+ def remove_column(self, index):
+ """Remove a column from the display and forget it from the column lists.
+ """
+ column = self.columns.pop(index)
+ del self.attr_map_for_column[column._column]
+ del self.gtk_column_to_wrapper[column._column]
+ self._widget.remove_column(column._column)
+
+ def get_columns(self):
+ """Returns the current columns, in order, by title."""
+ # FIXME: this should probably return column objects, and really should
+ # not be keeping track of columns by title at all
+ titles = [column.get_title().decode('utf-8')
+ for column in self._widget.get_columns()]
+ return titles
+
+ def add_column(self, column):
+ """Append a column to this table; setup all necessary mappings, and
+ setup the new column's properties to match the table's settings.
+ """
+ self.model.check_new_column(column)
+ self._widget.append_column(column._column)
+ self.columns.append(column)
+ self.attr_map_for_column[column._column] = column.attrs
+ self.gtk_column_to_wrapper[column._column] = column
+ self.setup_new_column(column)
+
+ def setup_new_column(self, column):
+ """Apply properties that we keep track of at the table level to a
+ newly-created column.
+ """
+ if self.background_color:
+ column.renderer._renderer.set_property('cell-background-gdk',
+ self.background_color)
+ column._column.set_reorderable(self._columns_draggable)
+ if column.do_horizontal_padding:
+ column.renderer._renderer.set_property('xpad', self._renderer_xpad)
+ column.renderer._renderer.set_property('ypad', self._renderer_ypad)
+
+ def set_column_spacing(self, space):
+ """Set the amount of space between columns."""
+ self._renderer_xpad = space / 2
+ for column in self.columns:
+ if column.do_horizontal_padding:
+ column.renderer._renderer.set_property('xpad',
+ self._renderer_xpad)
+
+ def set_row_spacing(self, space):
+ """Set the amount of space between columns."""
+ self._renderer_ypad = space / 2
+ for column in self.columns:
+ column.renderer._renderer.set_property('ypad', self._renderer_ypad)
+
+ def set_columns_draggable(self, setting):
+ """Set the draggability of existing and future columns."""
+ self._columns_draggable = setting
+ for column in self.columns:
+ column._column.set_reorderable(setting)
+
+ def set_column_background_color(self):
+ """Set the background color of existing columns to the table's
+ background_color.
+ """
+ for column in self.columns:
+ column.renderer._renderer.set_property('cell-background-gdk',
+ self.background_color)
+
+ def set_auto_resizes(self, setting):
+ # FIXME: to be implemented.
+ # At this point, GTK somehow does the right thing anyway in terms of
+ # auto-resizing. I'm not sure exactly what's happening, but I believe
+ # that if the column widths don't add up to the total width,
+ # gtk.TreeView allocates extra width for the last column. This works
+ # well enough for the tab list and item list, since there's only one
+ # column.
+ pass
+
+class HoverTrackingMixin(object):
+ """Handle mouse hover events - tooltips for some cells and hover events for
+ renderers which support them.
+ """
+ def __init__(self):
+ self.hover_info = None
+ self.hover_pos = None
+ if hasattr(self, 'get_tooltip'):
+ # this should probably be something like self.set_tooltip_source
+ self._widget.set_property('has-tooltip', True)
+ self.wrapped_widget_connect('query-tooltip', self.on_tooltip)
+ self._last_tooltip_place = None
+
+ def on_tooltip(self, treeview, x, y, keyboard_mode, tooltip):
+ # x, y are relative to the entire widget, but we want them to be
+ # relative to our bin window. The bin window doesn't include things
+ # like the column headers.
+ origin = treeview.window.get_origin()
+ bin_origin = treeview.get_bin_window().get_origin()
+ x += origin[0] - bin_origin[0]
+ y += origin[1] - bin_origin[1]
+ path_info = treeview.get_position_info(x, y)
+ if path_info is None:
+ self._last_tooltip_place = None
+ return False
+ if (self._last_tooltip_place is not None and
+ path_info[:2] != self._last_tooltip_place):
+ # the default GTK behavior is to keep the tooltip in the same
+ # position, but this is looks bad when we move to a different row.
+ # So return False once to stop this.
+ self._last_tooltip_place = None
+ return False
+ self._last_tooltip_place = path_info[:2]
+ iter_ = treeview.get_model().get_iter(path_info.path)
+ column = self.gtk_column_to_wrapper[path_info.column]
+ text = self.get_tooltip(iter_, column)
+ if text is None:
+ return False
+ pygtkhacks.set_tooltip_text(tooltip, text)
+ return True
+
+ def _update_hover(self, treeview, event):
+ old_hover_info, old_hover_pos = self.hover_info, self.hover_pos
+ path_info = treeview.get_position_info(event.x, event.y)
+ if (path_info and
+ self.gtk_column_to_wrapper[path_info.column].renderer.want_hover):
+ self.hover_info = path_info.path, path_info.column
+ self.hover_pos = path_info.x, path_info.y
+ else:
+ self.hover_info = None
+ self.hover_pos = None
+ if (old_hover_info != self.hover_info or
+ old_hover_pos != self.hover_pos):
+ if (old_hover_info != self.hover_info and
+ old_hover_info is not None):
+ self._redraw_cell(treeview, *old_hover_info)
+ if self.hover_info is not None:
+ self._redraw_cell(treeview, *self.hover_info)
+
+class GTKScrollbarOwnerMixin(ScrollbarOwnerMixin):
+ # XXX this is half a wrapper for TreeViewScrolling. A lot of things will
+ # become much simpler when we integrate TVS into this
+ def __init__(self):
+ ScrollbarOwnerMixin.__init__(self)
+ # super uses this for postponed scroll_to_iter
+ # it's a faux-signal from our _widget; this hack is only necessary until
+ # we integrate TVS
+ self._widget.scroll_range_changed = (lambda *a:
+ self.emit('scroll-range-changed'))
+
+ def set_scroller(self, scroller):
+ """Set the Scroller object for this widget, if its ScrolledWindow is
+ not a direct ancestor of the object. Standard View needs this.
+ """
+ self._widget.set_scroller(scroller._widget)
+
+ def _set_scroll_position(self, scroll_pos):
+ self._widget.set_scroll_position(scroll_pos)
+
+ def _get_item_area(self, iter_):
+ return self._widget.get_path_rect(self.get_path(iter_))
+
+ @property
+ def _manually_scrolled(self):
+ return self._widget.manually_scrolled
+
+ @property
+ def _position_set(self):
+ return self._widget.position_set
+
+ def _get_visible_area(self):
+ """Return the Rect of the visible area, in tree coords.
+
+ get_visible_rect gets this wrong for StandardView, always returning an
+ origin of (0, 0) - this is because our ScrolledWindow is not our direct
+ parent.
+ """
+ bars = self._widget._scrollbars
+ x, y = (int(adj.get_value()) for adj in bars)
+ width, height = (int(adj.get_page_size()) for adj in bars)
+ if height == 0:
+ # this happens even after _widget._coords_working
+ raise WidgetNotReadyError('visible height')
+ return Rect(x, y, width, height)
+
+ def _get_scroll_position(self):
+ """Get the current position of both scrollbars, to restore later."""
+ try:
+ return tuple(int(bar.get_value()) for bar in self._widget._scrollbars)
+ except WidgetNotReadyError:
+ return None
+
+class TableView(Widget, GTKSelectionOwnerMixin, DNDHandlerMixin,
+ HotspotTrackingMixin, ColumnOwnerMixin, HoverTrackingMixin,
+ GTKScrollbarOwnerMixin):
+ """https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
+
+ draws_selection = True
+
+ def __init__(self, model, custom_headers=False):
+ Widget.__init__(self)
+ self.set_widget(MiroTreeView())
+ self.model = model
+ self.model.add_to_tableview(self._widget)
+ self._model = self._widget.get_model()
+ wrappermap.add(self._model, model)
+ self._setup_colors()
+ self.background_color = None
+ self.context_menu_callback = None
+ self.in_bulk_change = False
+ self.delaying_press = False
+ self._use_custom_headers = False
+ self.layout_manager = LayoutManager(self._widget)
+ self.height_changed = None # 17178 hack
+ self._connect_signals()
+ # setting up mixins after general TableView init
+ GTKSelectionOwnerMixin.__init__(self)
+ DNDHandlerMixin.__init__(self)
+ HotspotTrackingMixin.__init__(self)
+ ColumnOwnerMixin.__init__(self)
+ HoverTrackingMixin.__init__(self)
+ GTKScrollbarOwnerMixin.__init__(self)
+ if custom_headers:
+ self._enable_custom_headers()
+
+ # FIXME: should implement set_model() and make None a special case.
+ def unset_model(self):
+ """Disconnect our model from this table view.
+
+ This should be called when you want to destroy a TableView and
+ there's a new TableView sharing its model.
+ """
+ self._widget.set_model(None)
+ self.model = None
+
+ def _connect_signals(self):
+ self.create_signal('row-expanded')
+ self.create_signal('row-collapsed')
+ self.create_signal('row-clicked')
+ self.create_signal('row-activated')
+ self.wrapped_widget_connect('row-activated', self.on_row_activated)
+ self.wrapped_widget_connect('row-expanded', self.on_row_expanded)
+ self.wrapped_widget_connect('row-collapsed', self.on_row_collapsed)
+ self.wrapped_widget_connect('button-press-event', self.on_button_press)
+ self.wrapped_widget_connect('button-release-event',
+ self.on_button_release)
+ self.wrapped_widget_connect('motion-notify-event',
+ self.on_motion_notify)
+
+ def set_gradient_highlight(self, gradient):
+ # This is just an OS X thing.
+ pass
+
+ def set_background_color(self, color):
+ self.background_color = self.make_color(color)
+ self.modify_style('base', gtk.STATE_NORMAL, self.background_color)
+ if not self.draws_selection:
+ self.modify_style('base', gtk.STATE_SELECTED,
+ self.background_color)
+ self.modify_style('base', gtk.STATE_ACTIVE, self.background_color)
+ if self.use_custom_style:
+ self.set_column_background_color()
+
+ def set_group_lines_enabled(self, enabled):
+ """Enable/Disable group lines.
+
+ This only has an effect if our model is an InfoListModel and it has a
+ grouping set.
+
+ If group lines are enabled, we will draw a line below the last item in
+ the group. Use set_group_line_style() to change the look of the line.
+ """
+ self._widget.group_lines_enabled = enabled
+ self.queue_redraw()
+
+ def set_group_line_style(self, color, width):
+ self._widget.group_line_color = color
+ self._widget.group_line_width = width
+ self.queue_redraw()
+
+ def handle_custom_style_change(self):
+ if self.background_color is not None:
+ if self.use_custom_style:
+ self.set_column_background_color()
+ else:
+ for column in self.columns:
+ column.renderer._renderer.set_property(
+ 'cell-background-set', False)
+
+ def set_alternate_row_backgrounds(self, setting):
+ self._widget.set_rules_hint(setting)
+
+ def set_grid_lines(self, horizontal, vertical):
+ if horizontal and vertical:
+ setting = gtk.TREE_VIEW_GRID_LINES_BOTH
+ elif horizontal:
+ setting = gtk.TREE_VIEW_GRID_LINES_HORIZONTAL
+ elif vertical:
+ setting = gtk.TREE_VIEW_GRID_LINES_VERTICAL
+ else:
+ setting = gtk.TREE_VIEW_GRID_LINES_NONE
+ self._widget.set_grid_lines(setting)
+
+ def width_for_columns(self, total_width):
+ """Given the width allocated for the TableView, return how much of that
+ is available to column contents. Note that this depends on the number of
+ columns.
+ """
+ column_spacing = TableColumn.FIXED_PADDING * len(self.columns)
+ return total_width - column_spacing
+
+ def enable_album_view_focus_hack(self):
+ _install_album_view_gtkrc()
+ self._widget.set_name("miro-album-view")
+
+ def focus(self):
+ self._widget.grab_focus()
+
+ def _enable_custom_headers(self):
+ # NB: this is currently not used because the GTK tableview does not
+ # support custom headers.
+ self._use_custom_headers = True
+
+ def set_show_headers(self, show):
+ self._widget.set_headers_visible(show)
+ self._widget.set_headers_clickable(show)
+
+ def _setup_colors(self):
+ style = self._widget.style
+ if not self.draws_selection:
+ # if we don't want to draw selection, make the selected/active
+ # colors the same as the normal ones
+ self.modify_style('base', gtk.STATE_SELECTED,
+ style.base[gtk.STATE_NORMAL])
+ self.modify_style('base', gtk.STATE_ACTIVE,
+ style.base[gtk.STATE_NORMAL])
+
+ def set_search_column(self, model_index):
+ self._widget.set_search_column(model_index)
+
+ def set_fixed_height(self, fixed_height):
+ self._widget.set_fixed_height_mode(fixed_height)
+
+ def set_row_expanded(self, iter_, expanded):
+ """Expand or collapse the row specified by iter_. Succeeds or raises
+ WidgetActionError. Causes row-expanded or row-collapsed to be emitted
+ when successful.
+ """
+ path = self.get_path(iter_)
+ if expanded:
+ self._widget.expand_row(path, False)
+ else:
+ self._widget.collapse_row(path)
+ if bool(self._widget.row_expanded(path)) != bool(expanded):
+ raise WidgetActionError("cannot expand the given item - it "
+ "probably has no children.")
+
+ def is_row_expanded(self, iter_):
+ path = self.get_path(iter_)
+ return self._widget.row_expanded(path)
+
+ def set_context_menu_callback(self, callback):
+ self.context_menu_callback = callback
+
+ # GTK is really good and it is safe to operate on table even when
+ # cells may be constantly changing in flux.
+ def set_volatile(self, volatile):
+ return
+
+ def on_row_expanded(self, _widget, iter_, path):
+ self.emit('row-expanded', iter_, path)
+
+ def on_row_collapsed(self, _widget, iter_, path):
+ self.emit('row-collapsed', iter_, path)
+
+ def on_button_press(self, treeview, event):
+ """Handle a mouse button press"""
+ if event.type == gtk.gdk._2BUTTON_PRESS:
+ # already handled as row-activated
+ return False
+
+ path_info = treeview.get_position_info(event.x, event.y)
+ if not path_info:
+ # no item was clicked, so it's not going to be a hotspot, drag, or
+ # context menu
+ return False
+ if event.type == gtk.gdk.BUTTON_PRESS:
+ # single click; emit the event but keep on running so we can handle
+ # stuff like drag and drop.
+ if not self._x_coord_in_expander(treeview, path_info):
+ iter_ = treeview.get_model().get_iter(path_info.path)
+ self.emit('row-clicked', iter_)
+
+ if (event.button == 1 and self.handle_hotspot_hit(treeview, event)):
+ return True
+ if event.window != treeview.get_bin_window():
+ # click is outside the content area, don't try to handle this.
+ # In particular, our DnD code messes up resizing table columns.
+ return False
+ if (event.button == 1 and self.drag_source and
+ not self._x_coord_in_expander(treeview, path_info)):
+ return self.start_drag(treeview, event, path_info)
+ elif event.button == 3 and self.context_menu_callback:
+ self.show_context_menu(treeview, event, path_info)
+ return True
+
+ # FALLTHROUGH
+ return False
+
+ def show_context_menu(self, treeview, event, path_info):
+ """Pop up a context menu for the given click event (which is a
+ right-click on a row).
+ """
+ # hack for album view
+ if (treeview.group_lines_enabled and
+ path_info.column == treeview.get_columns()[0]):
+ self._select_all_rows_in_group(treeview, path_info.path)
+ self._popup_context_menu(path_info.path, event)
+ # grab keyboard focus since we handled the event
+ self.focus()
+
+ def _select_all_rows_in_group(self, treeview, path):
+ """Select all items in the group """
+
+ # FIXME: this is very tightly coupled with the portable code.
+
+ infolist = self.model
+ gtk_model = treeview.get_model()
+ if (not isinstance(infolist, InfoListModel) or
+ infolist.get_grouping() is None):
+ return
+ it = gtk_model.get_iter(path)
+ info, attrs, group_info = infolist.row_for_iter(it)
+ start_row = path[0] - group_info[0]
+ total_rows = group_info[1]
+
+ with self._ignoring_changes():
+ self.unselect_all()
+ for row in xrange(start_row, start_row + total_rows):
+ self.select_path((row,))
+ self.emit('selection-changed')
+
+ def _popup_context_menu(self, path, event):
+ if not self.selection.path_is_selected(path):
+ self.unselect_all(signal=False)
+ self.select_path(path)
+ menu = self.make_context_menu()
+ if menu:
+ menu.popup(None, None, None, event.button, event.time)
+ return menu
+ else:
+ return None
+
+ # XXX treeview.get_cell_area handles what we're trying to use this for
+ def _x_coord_in_expander(self, treeview, path_info):
+ """Calculate if an x coordinate is over the expander triangle
+
+ :param treeview: Gtk.TreeView
+ :param path_info: PathInfo(
+ tree path for the cell,
+ Gtk.TreeColumn,
+ x coordinate relative to column's cell area,
+ y coordinate relative to column's cell area (ignored),
+ )
+ """
+ if path_info.column != treeview.get_expander_column():
+ return False
+ model = treeview.get_model()
+ if not model.iter_has_child(model.get_iter(path_info.path)):
+ return False
+ # GTK allocateds an extra 4px to the right of the expanders. This
+ # seems to be hardcoded as EXPANDER_EXTRA_PADDING in the source code.
+ total_exander_size = treeview.expander_size + 4
+ # include horizontal_separator
+ # XXX: should this value be included in total_exander_size ?
+ offset = treeview.horizontal_separator / 2
+ # allocate space for expanders for parent nodes
+ expander_start = total_exander_size * (len(path_info.path) - 1) + offset
+ expander_end = expander_start + total_exander_size + offset
+ return expander_start <= path_info.x < expander_end
+
+ def on_row_activated(self, treeview, path, view_column):
+ iter_ = treeview.get_model().get_iter(path)
+ self.emit('row-activated', iter_)
+
+ def make_context_menu(self):
+ def gen_menu(menu_items):
+ menu = gtk.Menu()
+ for menu_item_info in menu_items:
+ if menu_item_info is None:
+ item = gtk.SeparatorMenuItem()
+ else:
+ label, callback = menu_item_info
+
+ if isinstance(label, tuple) and len(label) == 2:
+ text_label, icon_path = label
+ pixbuf = gtk.gdk.pixbuf_new_from_file(icon_path)
+ image = gtk.Image()
+ image.set_from_pixbuf(pixbuf)
+ item = gtk.ImageMenuItem(text_label)
+ item.set_image(image)
+ else:
+ item = gtk.MenuItem(label)
+
+ if callback is None:
+ item.set_sensitive(False)
+ elif isinstance(callback, list):
+ item.set_submenu(gen_menu(callback))
+ else:
+ item.connect('activate', self.on_context_menu_activate,
+ callback)
+ menu.append(item)
+ item.show()
+ return menu
+
+ items = self.context_menu_callback(self)
+ if items:
+ return gen_menu(items)
+ else:
+ return None
+
+ def on_context_menu_activate(self, item, callback):
+ callback()
+
+ def on_button_release(self, treeview, event):
+ if self.release_on_hotspot(event):
+ return True
+ if event.button == 1:
+ self.drag_button_down = False
+
+ if self.delaying_press:
+ # if dragging did not happen, unselect other rows and
+ # select current row
+ path_info = treeview.get_position_info(event.x, event.y)
+ if path_info is not None:
+ self.unselect_all(signal=False)
+ self.select_path(path_info.path)
+ self.delaying_press = False
+
+ def _redraw_cell(self, treeview, path, column):
+ cell_area = treeview.get_cell_area(path, column)
+ x, y = treeview.convert_bin_window_to_widget_coords(cell_area.x,
+ cell_area.y)
+ treeview.queue_draw_area(x, y, cell_area.width, cell_area.height)
+
+ def on_motion_notify(self, treeview, event):
+ self._update_hover(treeview, event)
+
+ if self.hotspot_tracker:
+ self.hotspot_tracker.update_position(event)
+ self.hotspot_tracker.update_hit()
+ return True
+
+ self.potential_drag_motion(treeview, event)
+ return None # XXX: used to fall through; not sure what retval does here
+
+ def start_bulk_change(self):
+ self._widget.freeze_child_notify()
+ self._widget.set_model(None)
+ self._disconnect_hotspot_signals()
+ self.in_bulk_change = True
+
+ def model_changed(self):
+ if self.in_bulk_change:
+ self._widget.set_model(self._model)
+ self._widget.thaw_child_notify()
+ self.hotspot_model_changed()
+ self.in_bulk_change = False
+
+ def get_path(self, iter_):
+ """Always use this rather than the model's get_path directly -
+ if the iter isn't valid, a GTK assertion causes us to exit
+ without warning; this wrapper changes that to a much more useful
+ AssertionError. Example related bug: #17362.
+ """
+ assert self.model.iter_is_valid(iter_)
+ return self._model.get_path(iter_)
+
+class TableModel(object):
+ """https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
+ MODEL_CLASS = gtk.ListStore
+
+ def __init__(self, *column_types):
+ self._model = self.MODEL_CLASS(*self.map_types(column_types))
+ self._column_types = column_types
+ if 'image' in self._column_types:
+ self.convert_row_for_gtk = self.convert_row_for_gtk_slow
+ self.convert_value_for_gtk = self.convert_value_for_gtk_slow
+ else:
+ self.convert_row_for_gtk = self.convert_row_for_gtk_fast
+ self.convert_value_for_gtk = self.convert_value_for_gtk_fast
+
+ def add_to_tableview(self, widget):
+ widget.set_model(self._model)
+
+ def map_types(self, miro_column_types):
+ type_map = {
+ 'boolean': bool,
+ 'numeric': float,
+ 'integer': int,
+ 'text': str,
+ 'image': gtk.gdk.Pixbuf,
+ 'datetime': object,
+ 'object': object,
+ }
+ try:
+ return [type_map[type] for type in miro_column_types]
+ except KeyError, e:
+ raise ValueError("Unknown column type: %s" % e[0])
+
+ # If we store image data, we need to do some work to convert row data to
+ # send to GTK
+ def convert_value_for_gtk_slow(self, column_value):
+ if isinstance(column_value, Image):
+ return column_value.pixbuf
+ else:
+ return column_value
+
+ def convert_row_for_gtk_slow(self, column_values):
+ return tuple(self.convert_value_for_gtk(c) for c in column_values)
+
+ def check_new_column(self, column):
+ for value in column.attrs.values():
+ if not isinstance(value, int):
+ msg = "Attribute values must be integers, not %r" % value
+ raise TypeError(msg)
+ if value < 0 or value >= len(self._column_types):
+ raise ValueError("Attribute index out of range: %s" % value)
+
+ # If we don't store image data, we can don't need to do any work to
+ # convert row data to gtk
+ def convert_value_for_gtk_fast(self, value):
+ return value
+
+ def convert_row_for_gtk_fast(self, column_values):
+ return column_values
+
+ def append(self, *column_values):
+ return self._model.append(self.convert_row_for_gtk(column_values))
+
+ def update_value(self, iter_, index, value):
+ assert self._model.iter_is_valid(iter_)
+ self._model.set(iter_, index, self.convert_value_for_gtk(value))
+
+ def update(self, iter_, *column_values):
+ self._model[iter_] = self.convert_value_for_gtk(column_values)
+
+ def remove(self, iter_):
+ if self._model.remove(iter_):
+ return iter_
+ else:
+ return None
+
+ def insert_before(self, iter_, *column_values):
+ row = self.convert_row_for_gtk(column_values)
+ return self._model.insert_before(iter_, row)
+
+ def first_iter(self):
+ return self._model.get_iter_first()
+
+ def next_iter(self, iter_):
+ return self._model.iter_next(iter_)
+
+ def nth_iter(self, index):
+ assert index >= 0
+ return self._model.iter_nth_child(None, index)
+
+ def __iter__(self):
+ return iter(self._model)
+
+ def __len__(self):
+ return len(self._model)
+
+ def __getitem__(self, iter_):
+ return self._model[iter_]
+
+ def get_rows(self, row_paths):
+ return [self._model[path] for path in row_paths]
+
+ def get_path(self, iter_):
+ return self._model.get_path(iter_)
+
+ def iter_is_valid(self, iter_):
+ return self._model.iter_is_valid(iter_)
+
+class TreeTableModel(TableModel):
+ """https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
+ MODEL_CLASS = gtk.TreeStore
+
+ def append(self, *column_values):
+ return self._model.append(None, self.convert_row_for_gtk(
+ column_values))
+
+ def insert_before(self, iter_, *column_values):
+ parent = self.parent_iter(iter_)
+ row = self.convert_row_for_gtk(column_values)
+ return self._model.insert_before(parent, iter_, row)
+
+ def append_child(self, iter_, *column_values):
+ return self._model.append(iter_, self.convert_row_for_gtk(
+ column_values))
+
+ def child_iter(self, iter_):
+ return self._model.iter_children(iter_)
+
+ def nth_child_iter(self, iter_, index):
+ assert index >= 0
+ return self._model.iter_nth_child(iter_, index)
+
+ def has_child(self, iter_):
+ return self._model.iter_has_child(iter_)
+
+ def children_count(self, iter_):
+ return self._model.iter_n_children(iter_)
+
+ def parent_iter(self, iter_):
+ assert self._model.iter_is_valid(iter_)
+ return self._model.iter_parent(iter_)