diff options
Diffstat (limited to 'lvc/widgets/gtk/tableview.py')
-rw-r--r-- | lvc/widgets/gtk/tableview.py | 1557 |
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_) |