PageRenderTime 64ms CodeModel.GetById 24ms RepoModel.GetById 0ms app.codeStats 1ms

/tv/lib/frontends/widgets/gtk/tableview.py

https://github.com/kazcw/miro
Python | 1223 lines | 879 code | 104 blank | 240 comment | 119 complexity | 1e7f93767e1371208f20471366d174e0 MD5 | raw file
  1. # Miro - an RSS based video player application
  2. # Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
  3. # Participatory Culture Foundation
  4. #
  5. # This program is free software; you can redistribute it and/or modify
  6. # it under the terms of the GNU General Public License as published by
  7. # the Free Software Foundation; either version 2 of the License, or
  8. # (at your option) any later version.
  9. #
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU General Public License
  16. # along with this program; if not, write to the Free Software
  17. # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
  18. #
  19. # In addition, as a special exception, the copyright holders give
  20. # permission to link the code of portions of this program with the OpenSSL
  21. # library.
  22. #
  23. # You must obey the GNU General Public License in all respects for all of
  24. # the code used other than OpenSSL. If you modify file(s) with this
  25. # exception, you may extend this exception to your version of the file(s),
  26. # but you are not obligated to do so. If you do not wish to do so, delete
  27. # this exception statement from your version. If you delete this exception
  28. # statement from all source files in the program, then also delete it here.
  29. """tableview.py -- Wrapper for the GTKTreeView widget. It's used for the tab
  30. list and the item list (AKA almost all of the miro).
  31. """
  32. import logging
  33. import itertools
  34. import gobject
  35. import gtk
  36. from collections import namedtuple
  37. # These are probably wrong, and are placeholders for now, until custom headers
  38. # are also implemented for GTK.
  39. CUSTOM_HEADER_HEIGHT = 25
  40. HEADER_HEIGHT = 25
  41. from miro import signals
  42. from miro.errors import (WidgetActionError, WidgetDomainError, WidgetRangeError,
  43. WidgetNotReadyError)
  44. from miro.frontends.widgets.tableselection import SelectionOwnerMixin
  45. from miro.frontends.widgets.tablescroll import ScrollbarOwnerMixin
  46. from miro.frontends.widgets.gtk import pygtkhacks
  47. from miro.frontends.widgets.gtk import drawing
  48. from miro.frontends.widgets.gtk import fixedliststore
  49. from miro.frontends.widgets.gtk import wrappermap
  50. from miro.frontends.widgets.gtk.base import Widget
  51. from miro.frontends.widgets.gtk.simple import Image
  52. from miro.frontends.widgets.gtk.layoutmanager import LayoutManager
  53. from miro.frontends.widgets.gtk.weakconnect import weak_connect
  54. from miro.frontends.widgets.gtk.tableviewcells import (GTKCustomCellRenderer,
  55. GTKCheckboxCellRenderer, ItemListRenderer, ItemListRendererText)
  56. PathInfo = namedtuple('PathInfo', 'path column x y')
  57. Rect = namedtuple('Rect', 'x y width height')
  58. _album_view_gtkrc_installed = False
  59. def _install_album_view_gtkrc():
  60. """Hack for styling GTKTreeView for the album view widget.
  61. We do a couple things:
  62. - Remove the focus ring
  63. - Remove any separator space.
  64. We do this so that we don't draw a box through the album view column for
  65. selected rows.
  66. """
  67. global _album_view_gtkrc_installed
  68. if _album_view_gtkrc_installed:
  69. return
  70. rc_string = ('style "album-view-style"\n'
  71. '{ \n'
  72. ' GtkTreeView::vertical-separator = 0\n'
  73. ' GtkTreeView::horizontal-separator = 0\n'
  74. ' GtkWidget::focus-line-width = 0 \n'
  75. '}\n'
  76. 'widget "*.miro-album-view" style "album-view-style"\n')
  77. gtk.rc_parse_string(rc_string)
  78. _album_view_gtkrc_installed = True
  79. def rect_contains_rect(outside, inside):
  80. # currently unused
  81. return (outside.x <= inside.x and
  82. outside.y <= inside.y and
  83. outside.x + outside.width >= inside.x + inside.width and
  84. outside.y + outside.height >= inside.y + inside.height)
  85. def rect_contains_point(rect, x, y):
  86. return ((rect.x <= x < rect.x + rect.width) and
  87. (rect.y <= y < rect.y + rect.height))
  88. class TreeViewScrolling(object):
  89. def __init__(self):
  90. self.scrollbars = []
  91. self.scroll_positions = None, None
  92. self.restoring_scroll = None
  93. self.connect('parent-set', self.on_parent_set)
  94. self.scroller = None
  95. # hack necessary because of our weird widget hierarchy (GTK doesn't deal
  96. # well with the Scroller's widget not being the direct parent of the
  97. # TableView's widget.)
  98. self._coords_working = False
  99. def scroll_range_changed(self):
  100. """Faux-signal; this should all be integrated into
  101. GTKScrollbarOwnerMixin, making this unnecessary.
  102. """
  103. @property
  104. def manually_scrolled(self):
  105. """Return whether the view has been scrolled explicitly by the user
  106. since the last time it was set automatically.
  107. """
  108. auto_pos = self.scroll_positions[1]
  109. if auto_pos is None:
  110. # if we don't have any position yet, user can't have manually
  111. # scrolled
  112. return False
  113. real_pos = self.scrollbars[1].get_value()
  114. return abs(auto_pos - real_pos) > 5 # allowing some fuzziness
  115. @property
  116. def position_set(self):
  117. """Return whether the scroll position has been set in any way."""
  118. return any(x is not None for x in self.scroll_positions)
  119. def on_parent_set(self, widget, old_parent):
  120. """We have parent window now; we need to control its scrollbars."""
  121. self.set_scroller(widget.get_parent())
  122. def set_scroller(self, window):
  123. """Take control of the scrollbars of window."""
  124. if not isinstance(window, gtk.ScrolledWindow):
  125. return
  126. self.scroller = window
  127. scrollbars = tuple(bar.get_adjustment()
  128. for bar in (window.get_hscrollbar(), window.get_vscrollbar()))
  129. self.scrollbars = scrollbars
  130. for i, bar in enumerate(scrollbars):
  131. weak_connect(bar, 'changed', self.on_scroll_range_changed, i)
  132. if self.restoring_scroll:
  133. self.set_scroll_position(self.restoring_scroll)
  134. def on_scroll_range_changed(self, adjustment, bar):
  135. """The scrollbar might have a range now. Set its initial position if
  136. we haven't already.
  137. """
  138. self._coords_working = True
  139. if self.restoring_scroll:
  140. self.set_scroll_position(self.restoring_scroll)
  141. # our wrapper handles the same thing for iters
  142. self.scroll_range_changed()
  143. def set_scroll_position(self, scroll_position):
  144. """Restore the scrollbars to a remembered state."""
  145. try:
  146. self.scroll_positions = tuple(self._clip_pos(adj, x)
  147. for adj, x in zip(self.scrollbars, scroll_position))
  148. except WidgetActionError, error:
  149. logging.debug("can't scroll yet: %s", error.reason)
  150. # try again later
  151. self.restoring_scroll = scroll_position
  152. else:
  153. for adj, pos in zip(self.scrollbars, self.scroll_positions):
  154. adj.set_value(pos)
  155. self.restoring_scroll = None
  156. def _clip_pos(self, adj, pos):
  157. lower = adj.get_lower()
  158. upper = adj.get_upper() - adj.get_page_size()
  159. # currently, StandardView gets an upper of 2.0 when it's not ready
  160. # FIXME: don't count on that
  161. if pos > upper and upper < 5:
  162. raise WidgetRangeError("scrollable area", pos, lower, upper)
  163. return min(max(pos, lower), upper)
  164. def get_path_rect(self, path):
  165. """Return the Rect for the given item, in tree coords."""
  166. if not self._coords_working:
  167. # part of solution to #17405; widget_to_tree_coords tends to return
  168. # y=8 before the first scroll-range-changed signal. ugh.
  169. raise WidgetNotReadyError('_coords_working')
  170. rect = self.get_background_area(path, self.get_columns()[0])
  171. x, y = self.widget_to_tree_coords(rect.x, rect.y)
  172. return Rect(x, y, rect.width, rect.height)
  173. @property
  174. def _scrollbars(self):
  175. if not self.scrollbars:
  176. raise WidgetNotReadyError
  177. return self.scrollbars
  178. def scroll_ancestor(self, newly_selected, down):
  179. # Try to figure out what just became selected. If multiple things
  180. # somehow became selected, select the outermost one
  181. if len(newly_selected) == 0:
  182. raise WidgetActionError("need at an item to scroll to")
  183. if down:
  184. path_to_show = max(newly_selected)
  185. else:
  186. path_to_show = min(newly_selected)
  187. if not self.scrollbars:
  188. return
  189. vadjustment = self.scrollbars[1]
  190. rect = self.get_background_area(path_to_show, self.get_columns()[0])
  191. _, top = self.translate_coordinates(self.scroller, 0, rect.y)
  192. top += vadjustment.value
  193. bottom = top + rect.height
  194. if down:
  195. if bottom > vadjustment.value + vadjustment.page_size:
  196. bottom_value = min(bottom, vadjustment.upper)
  197. vadjustment.set_value(bottom_value - vadjustment.page_size)
  198. else:
  199. if top < vadjustment.value:
  200. vadjustment.set_value(max(vadjustment.lower, top))
  201. class MiroTreeView(gtk.TreeView, TreeViewScrolling):
  202. """Extends the GTK TreeView widget to help implement TableView
  203. https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
  204. # Add a tiny bit of padding so that the user can drag feeds below
  205. # the table, i.e. to the bottom row, as a top-level
  206. PAD_BOTTOM = 3
  207. def __init__(self):
  208. gtk.TreeView.__init__(self)
  209. TreeViewScrolling.__init__(self)
  210. self.drag_dest_at_bottom = False
  211. self.height_without_pad_bottom = -1
  212. self.set_enable_search(False)
  213. self.horizontal_separator = self.style_get_property("horizontal-separator")
  214. self.expander_size = self.style_get_property("expander-size")
  215. self.group_lines_enabled = False
  216. self.group_line_color = (0, 0, 0)
  217. self.group_line_width = 1
  218. self._scroll_before_model_change = None
  219. def do_size_request(self, req):
  220. gtk.TreeView.do_size_request(self, req)
  221. self.height_without_pad_bottom = req.height
  222. req.height += self.PAD_BOTTOM
  223. def set_drag_dest_at_bottom(self, value):
  224. if value != self.drag_dest_at_bottom:
  225. self.drag_dest_at_bottom = value
  226. x1, x2, y = self.bottom_drag_dest_coords()
  227. area = gtk.gdk.Rectangle(x1-1, y-1, x2-x1+2,2)
  228. self.window.invalidate_rect(area, True)
  229. def do_move_cursor(self, step, count):
  230. if step == gtk.MOVEMENT_VISUAL_POSITIONS:
  231. # GTK is asking us to move left/right. Since our TableViews don't
  232. # support this, return False to let the key press propagate. See
  233. # #15646 for more info.
  234. return False
  235. if isinstance(self.get_parent(), gtk.ScrolledWindow):
  236. # If our parent is a ScrolledWindow, let GTK take care of this
  237. handled = gtk.TreeView.do_move_cursor(self, step, count)
  238. return handled
  239. else:
  240. # Otherwise, we have to search up the widget tree for a
  241. # ScrolledWindow to take care of it
  242. selection = self.get_selection()
  243. model, start_selection = selection.get_selected_rows()
  244. gtk.TreeView.do_move_cursor(self, step, count)
  245. model, end_selection = selection.get_selected_rows()
  246. newly_selected = set(end_selection) - set(start_selection)
  247. down = (count > 0)
  248. try:
  249. self.scroll_ancestor(newly_selected, down)
  250. except WidgetActionError:
  251. # not possible
  252. return False
  253. return True
  254. def set_drag_dest_row(self, row, position):
  255. """Works like set_drag_dest_row, except row can be None which will
  256. cause the treeview to set the drag indicator below the bottom of the
  257. TreeView. This is slightly different than below the last row of the
  258. tree, since the last row might be a child row.
  259. set_drag_dest_at_bottom() makes the TreeView indicate that the drop
  260. will be appended as a top-level row.
  261. """
  262. if row is not None:
  263. gtk.TreeView.set_drag_dest_row(self, row, position)
  264. self.set_drag_dest_at_bottom(False)
  265. else:
  266. pygtkhacks.unset_tree_view_drag_dest_row(self)
  267. self.set_drag_dest_at_bottom(True)
  268. def unset_drag_dest_row(self):
  269. pygtkhacks.unset_tree_view_drag_dest_row(self)
  270. self.set_drag_dest_at_bottom(False)
  271. def do_expose_event(self, event):
  272. if self._scroll_before_model_change is not None:
  273. self._restore_scroll_after_model_change()
  274. gtk.TreeView.do_expose_event(self, event)
  275. if self.drag_dest_at_bottom:
  276. gc = self.get_style().fg_gc[self.state]
  277. x1, x2, y = self.bottom_drag_dest_coords()
  278. event.window.draw_line(gc, x1, y, x2, y)
  279. if self.group_lines_enabled and event.window == self.get_bin_window():
  280. self.draw_group_lines(event)
  281. def draw_group_lines(self, expose_event):
  282. # we need both the GTK TreeModel and the ItemList for this one
  283. gtk_model = self.get_model()
  284. modelwrapper = wrappermap.wrapper(self).model
  285. if (not isinstance(modelwrapper, ItemListModel) or
  286. modelwrapper.item_list.group_func is None):
  287. return
  288. # prepare a couple variables for the drawing
  289. expose_bottom = expose_event.area.y + expose_event.area.height
  290. cr = expose_event.window.cairo_create()
  291. cr.set_source_rgb(*self.group_line_color)
  292. first_column = self.get_columns()[0]
  293. # start on the top row of the expose event
  294. path_info = self.get_path_at_pos(expose_event.area.x, expose_event.area.y)
  295. if path_info is None:
  296. return
  297. else:
  298. path = path_info[0]
  299. gtk_iter = gtk_model.get_iter(path)
  300. # draw the lines
  301. while True:
  302. # calculate the row's area in the y direction. We don't care
  303. # about the x-axis, but PyGTK forces us to pass in a column, so we
  304. # send in the first one and ignore the x/width attributes.
  305. background_area = self.get_background_area(path, first_column)
  306. if background_area.y > expose_bottom:
  307. break
  308. # draw stuff if we're on the last row
  309. index = gtk_model.row_of_iter(gtk_iter)
  310. group_info = modelwrapper.item_list.get_group_info(index)
  311. if group_info[0] == group_info[1] - 1:
  312. y = (background_area.y + background_area.height -
  313. self.group_line_width)
  314. cr.rectangle(expose_event.area.x, y, expose_event.area.width,
  315. self.group_line_width)
  316. cr.fill()
  317. # prepare for next row
  318. gtk_iter = gtk_model.iter_next(gtk_iter)
  319. if gtk_iter is None:
  320. break
  321. path = (path[0] + 1,)
  322. def bottom_drag_dest_coords(self):
  323. visible = self.get_visible_rect()
  324. x1 = visible.x
  325. x2 = visible.x + visible.width
  326. y = visible.height - self.PAD_BOTTOM
  327. x1, _ = self.tree_to_widget_coords(x1, y)
  328. x2, y = self.tree_to_widget_coords(x2, y)
  329. return x1, x2, y
  330. def get_position_info(self, x, y):
  331. """Wrapper for get_path_at_pos that converts the path_info to a named
  332. tuple and handles rounding the coordinates.
  333. """
  334. path_info = self.get_path_at_pos(int(round(x)), int(round(y)))
  335. if path_info:
  336. return PathInfo(*path_info)
  337. def save_scroll_position_before_model_change(self):
  338. """This method implements a hack to keep our scroll position when we
  339. change our model.
  340. For performance reasons, sometimes it's better to to change a model
  341. than keep a model in place and make a bunch of changes to it (we
  342. currently do this for ItemListModel). However, one issue that we run
  343. into is that when we set the new model, the scroll position is lost.
  344. Call this method before changing the model to keep the scroll
  345. position between changes.
  346. """
  347. vadjustment = self.get_vadjustment()
  348. hadjustment = self.get_hadjustment()
  349. self._scroll_before_model_change = \
  350. (vadjustment.get_value(), hadjustment.get_value())
  351. def _restore_scroll_after_model_change(self):
  352. v_value, h_value = self._scroll_before_model_change
  353. self._scroll_before_model_change = None
  354. self.get_vadjustment().set_value(v_value)
  355. self.get_hadjustment().set_value(h_value)
  356. gobject.type_register(MiroTreeView)
  357. class HotspotTracker(object):
  358. """Handles tracking hotspots.
  359. https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
  360. def __init__(self, treeview, event):
  361. self.treeview = treeview
  362. self.treeview_wrapper = wrappermap.wrapper(treeview)
  363. self.hit = False
  364. self.button = event.button
  365. path_info = treeview.get_position_info(event.x, event.y)
  366. if path_info is None:
  367. return
  368. self.path, self.column, background_x, background_y = path_info
  369. # We always pack 1 renderer for each column
  370. gtk_renderer = self.column.get_cell_renderers()[0]
  371. if not isinstance(gtk_renderer, GTKCustomCellRenderer):
  372. return
  373. self.renderer = wrappermap.wrapper(gtk_renderer)
  374. self.attr_map = self.treeview_wrapper.attr_map_for_column[self.column]
  375. if not rect_contains_point(self.calc_cell_area(), event.x, event.y):
  376. # Mouse is in the padding around the actual cell area
  377. return
  378. self.update_position(event)
  379. self.iter = treeview.get_model().get_iter(self.path)
  380. self.name = self.calc_hotspot()
  381. if self.name is not None:
  382. self.hit = True
  383. def is_for_context_menu(self):
  384. return self.name == "#show-context-menu"
  385. def calc_cell_area(self):
  386. cell_area = self.treeview.get_cell_area(self.path, self.column)
  387. xpad = self.renderer._renderer.props.xpad
  388. ypad = self.renderer._renderer.props.ypad
  389. cell_area.x += xpad
  390. cell_area.y += ypad
  391. cell_area.width -= xpad * 2
  392. cell_area.height -= ypad * 2
  393. return cell_area
  394. def update_position(self, event):
  395. self.x, self.y = int(event.x), int(event.y)
  396. def calc_cell_state(self):
  397. if self.treeview.get_selection().path_is_selected(self.path):
  398. if self.treeview.flags() & gtk.HAS_FOCUS:
  399. return gtk.STATE_SELECTED
  400. else:
  401. return gtk.STATE_ACTIVE
  402. else:
  403. return gtk.STATE_NORMAL
  404. def calc_hotspot(self):
  405. cell_area = self.calc_cell_area()
  406. if rect_contains_point(cell_area, self.x, self.y):
  407. model = self.treeview.get_model()
  408. self.renderer.cell_data_func(self.column, self.renderer._renderer,
  409. model, self.iter, self.attr_map)
  410. style = drawing.DrawingStyle(self.treeview_wrapper,
  411. use_base_color=True, state=self.calc_cell_state())
  412. x = self.x - cell_area.x
  413. y = self.y - cell_area.y
  414. return self.renderer.hotspot_test(style,
  415. self.treeview_wrapper.layout_manager,
  416. x, y, cell_area.width, cell_area.height)
  417. else:
  418. return None
  419. def update_hit(self):
  420. if self.is_for_context_menu():
  421. return # we always keep hit = True for this one
  422. old_hit = self.hit
  423. self.hit = (self.calc_hotspot() == self.name)
  424. if self.hit != old_hit:
  425. self.redraw_cell()
  426. def redraw_cell(self):
  427. # Check that the treeview is still around. We might have switched
  428. # views in response to a hotspot being clicked.
  429. if self.treeview.flags() & gtk.REALIZED:
  430. cell_area = self.treeview.get_cell_area(self.path, self.column)
  431. x, y = self.treeview.tree_to_widget_coords(cell_area.x,
  432. cell_area.y)
  433. self.treeview.queue_draw_area(x, y,
  434. cell_area.width, cell_area.height)
  435. class TableColumn(signals.SignalEmitter):
  436. """A single column of a TableView.
  437. Signals:
  438. clicked (table_column) -- The header for this column was clicked.
  439. """
  440. # GTK hard-codes 4px of padding for each column
  441. FIXED_PADDING = 4
  442. def __init__(self, title, renderer, header=None, **attrs):
  443. # header widget not used yet in GTK (#15800)
  444. signals.SignalEmitter.__init__(self)
  445. self.create_signal('clicked')
  446. self._column = gtk.TreeViewColumn(title, renderer._renderer)
  447. self._column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
  448. self._column.set_clickable(True)
  449. self.attrs = attrs
  450. renderer.setup_attributes(self._column, attrs)
  451. self.renderer = renderer
  452. weak_connect(self._column, 'clicked', self._header_clicked)
  453. self.do_horizontal_padding = True
  454. def set_right_aligned(self, right_aligned):
  455. """Horizontal alignment of the header label."""
  456. if right_aligned:
  457. self._column.set_alignment(1.0)
  458. else:
  459. self._column.set_alignment(0.0)
  460. def set_min_width(self, width):
  461. self._column.props.min_width = width + TableColumn.FIXED_PADDING
  462. def set_max_width(self, width):
  463. self._column.props.max_width = width
  464. def set_width(self, width):
  465. self._column.set_fixed_width(width + TableColumn.FIXED_PADDING)
  466. def get_width(self):
  467. return self._column.get_width()
  468. def _header_clicked(self, tablecolumn):
  469. self.emit('clicked')
  470. def set_resizable(self, resizable):
  471. """Set if the user can resize the column."""
  472. self._column.set_resizable(resizable)
  473. def set_do_horizontal_padding(self, horizontal_padding):
  474. self.do_horizontal_padding = False
  475. def set_sort_indicator_visible(self, visible):
  476. """Show/Hide the sort indicator for this column."""
  477. self._column.set_sort_indicator(visible)
  478. def get_sort_indicator_visible(self):
  479. return self._column.get_sort_indicator()
  480. def set_sort_order(self, ascending):
  481. """Display a sort indicator on the column header. Ascending can be
  482. either True or False which affects the direction of the indicator.
  483. """
  484. if ascending:
  485. self._column.set_sort_order(gtk.SORT_ASCENDING)
  486. else:
  487. self._column.set_sort_order(gtk.SORT_DESCENDING)
  488. def get_sort_order_ascending(self):
  489. """Returns if the sort indicator is displaying that the sort is
  490. ascending.
  491. """
  492. return self._column.get_sort_order() == gtk.SORT_ASCENDING
  493. class GTKSelectionOwnerMixin(SelectionOwnerMixin):
  494. """GTK-specific methods for selection management.
  495. This subclass should not define any behavior. Methods that cannot be
  496. completed in this widget state should raise WidgetActionError.
  497. """
  498. def __init__(self):
  499. SelectionOwnerMixin.__init__(self)
  500. self.selection = self._widget.get_selection()
  501. weak_connect(self.selection, 'changed', self.on_selection_changed)
  502. def _set_allow_multiple_select(self, allow):
  503. if allow:
  504. mode = gtk.SELECTION_MULTIPLE
  505. else:
  506. mode = gtk.SELECTION_SINGLE
  507. self.selection.set_mode(mode)
  508. def _get_allow_multiple_select(self):
  509. return self.selection.get_mode() == gtk.SELECTION_MULTIPLE
  510. def _get_selected_iters(self):
  511. iters = []
  512. def collect(treemodel, path, iter_):
  513. iters.append(iter_)
  514. self.selection.selected_foreach(collect)
  515. return iters
  516. def _get_selected_iter(self):
  517. model, iter_ = self.selection.get_selected()
  518. return iter_
  519. @property
  520. def num_rows_selected(self):
  521. return self.selection.count_selected_rows()
  522. def _is_selected(self, iter_):
  523. return self.selection.iter_is_selected(iter_)
  524. def _select(self, iter_):
  525. self.selection.select_iter(iter_)
  526. def _unselect(self, iter_):
  527. self.selection.unselect_iter(iter_)
  528. def _unselect_all(self):
  529. self.selection.unselect_all()
  530. def _iter_to_string(self, iter_):
  531. return self.gtk_model.get_string_from_iter(iter_)
  532. def _iter_from_string(self, string):
  533. try:
  534. return self.gtk_model.get_iter_from_string(string)
  535. except ValueError:
  536. raise WidgetDomainError(
  537. "model iters", string, "%s other iters" % len(self.model))
  538. def select_path(self, path):
  539. self.selection.select_path(path)
  540. def _validate_iter(self, iter_):
  541. if self.get_path(iter_) is None:
  542. raise WidgetDomainError(
  543. "model iters", iter_, "%s other iters" % len(self.model))
  544. real_model = self._widget.get_model()
  545. if not real_model:
  546. raise WidgetActionError("no model")
  547. elif real_model != self.gtk_model:
  548. raise WidgetActionError("wrong model?")
  549. def get_cursor(self):
  550. """Return the path of the 'focused' item."""
  551. path, column = self._widget.get_cursor()
  552. return path
  553. def set_cursor(self, path):
  554. """Set the path of the 'focused' item."""
  555. if path is None:
  556. # XXX: is there a way to clear the cursor?
  557. return
  558. path_as_string = ':'.join(str(component) for component in path)
  559. with self.preserving_selection(): # set_cursor() messes up the selection
  560. self._widget.set_cursor(path_as_string)
  561. class DNDHandlerMixin(object):
  562. """TableView row DnD.
  563. Depends on arbitrary TableView methods; otherwise self-contained except:
  564. on_button_press: may call start_drag
  565. on_button_release: may unset drag_button_down
  566. on_motion_notify: may call potential_drag_motion
  567. """
  568. def __init__(self):
  569. self.drag_button_down = False
  570. self.drag_data = {}
  571. self.drag_source = self.drag_dest = None
  572. self.drag_start_x, self.drag_start_y = None, None
  573. self.wrapped_widget_connect('drag-data-get', self.on_drag_data_get)
  574. self.wrapped_widget_connect('drag-end', self.on_drag_end)
  575. self.wrapped_widget_connect('drag-motion', self.on_drag_motion)
  576. self.wrapped_widget_connect('drag-leave', self.on_drag_leave)
  577. self.wrapped_widget_connect('drag-drop', self.on_drag_drop)
  578. self.wrapped_widget_connect('drag-data-received',
  579. self.on_drag_data_received)
  580. self.wrapped_widget_connect('unrealize', self.on_drag_unrealize)
  581. def set_drag_source(self, drag_source):
  582. self.drag_source = drag_source
  583. # XXX: the following note no longer seems accurate:
  584. # No need to call enable_model_drag_source() here, we handle it
  585. # ourselves in on_motion_notify()
  586. def set_drag_dest(self, drag_dest):
  587. """Set the drop handler."""
  588. self.drag_dest = drag_dest
  589. if drag_dest is not None:
  590. targets = self._gtk_target_list(drag_dest.allowed_types())
  591. self._widget.enable_model_drag_dest(targets,
  592. drag_dest.allowed_actions())
  593. self._widget.drag_dest_set(0, targets,
  594. drag_dest.allowed_actions())
  595. else:
  596. self._widget.unset_rows_drag_dest()
  597. self._widget.drag_dest_unset()
  598. def start_drag(self, treeview, event, path_info):
  599. """Check whether the event is a drag event; return whether handled
  600. here.
  601. """
  602. if event.state & (gtk.gdk.CONTROL_MASK | gtk.gdk.SHIFT_MASK):
  603. return False
  604. model, row_paths = treeview.get_selection().get_selected_rows()
  605. if path_info.path not in row_paths:
  606. # something outside the selection is being dragged.
  607. # make it the new selection.
  608. self.unselect_all(signal=False)
  609. self.select_path(path_info.path)
  610. row_paths = [path_info.path]
  611. rows = self.model.get_rows(row_paths)
  612. self.drag_data = rows and self.drag_source.begin_drag(self, rows)
  613. self.drag_button_down = bool(self.drag_data)
  614. if self.drag_button_down:
  615. self.drag_start_x = int(event.x)
  616. self.drag_start_y = int(event.y)
  617. if len(row_paths) > 1 and path_info.path in row_paths:
  618. # handle multiple selection. If the current row is already
  619. # selected, stop propagating the signal. We will only change
  620. # the selection if the user doesn't start a DnD operation.
  621. # This makes it more natural for the user to drag a block of
  622. # selected items.
  623. renderer = path_info.column.get_cell_renderers()[0]
  624. if (not self._x_coord_in_expander(treeview, path_info)
  625. and not isinstance(renderer, GTKCheckboxCellRenderer)):
  626. self.delaying_press = True
  627. # grab keyboard focus since we handled the event
  628. self.focus()
  629. return True
  630. def on_drag_data_get(self, treeview, context, selection, info, timestamp):
  631. for typ, data in self.drag_data.items():
  632. selection.set(typ, 8, repr(data))
  633. def on_drag_end(self, treeview, context):
  634. self.drag_data = {}
  635. def find_type(self, drag_context):
  636. return self._widget.drag_dest_find_target(drag_context,
  637. self._widget.drag_dest_get_target_list())
  638. def calc_positions(self, x, y):
  639. """Given x and y coordinates, generate a list of drop positions to
  640. try. The values are tuples in the form of (parent_path, position,
  641. gtk_path, gtk_position), where parent_path and position is the
  642. position to send to the Miro code, and gtk_path and gtk_position is an
  643. equivalent position to send to the GTK code if the drag_dest validates
  644. the drop.
  645. """
  646. model = self.gtk_model
  647. try:
  648. gtk_path, gtk_position = self._widget.get_dest_row_at_pos(x, y)
  649. except TypeError:
  650. # Below the last row
  651. yield (None, model.iter_n_children(None), None, None)
  652. return
  653. iter_ = model.get_iter(gtk_path)
  654. if gtk_position in (gtk.TREE_VIEW_DROP_INTO_OR_BEFORE,
  655. gtk.TREE_VIEW_DROP_INTO_OR_AFTER):
  656. yield (iter_, -1, gtk_path, gtk_position)
  657. assert model.iter_is_valid(iter_)
  658. parent_iter = model.iter_parent(iter_)
  659. position = gtk_path[-1]
  660. if gtk_position in (gtk.TREE_VIEW_DROP_BEFORE,
  661. gtk.TREE_VIEW_DROP_INTO_OR_BEFORE):
  662. # gtk gave us a "before" position, no need to change it
  663. yield (parent_iter, position, gtk_path, gtk.TREE_VIEW_DROP_BEFORE)
  664. else:
  665. # gtk gave us an "after" position, translate that to before the
  666. # next row for miro.
  667. if (self._widget.row_expanded(gtk_path) and
  668. model.iter_has_child(iter_)):
  669. child_path = gtk_path + (0,)
  670. yield (iter_, 0, child_path, gtk.TREE_VIEW_DROP_BEFORE)
  671. else:
  672. yield (parent_iter, position+1, gtk_path,
  673. gtk.TREE_VIEW_DROP_AFTER)
  674. def on_drag_motion(self, treeview, drag_context, x, y, timestamp):
  675. if not self.drag_dest:
  676. return True
  677. type = self.find_type(drag_context)
  678. if type == "NONE":
  679. drag_context.drag_status(0, timestamp)
  680. return True
  681. drop_action = 0
  682. for pos_info in self.calc_positions(x, y):
  683. drop_action = self.drag_dest.validate_drop(self, self.model, type,
  684. drag_context.actions, pos_info[0], pos_info[1])
  685. if isinstance(drop_action, (list, tuple)):
  686. drop_action, iter = drop_action
  687. path = self.model.get_path(iter)
  688. pos = gtk.TREE_VIEW_DROP_INTO_OR_BEFORE
  689. else:
  690. path, pos = pos_info[2:4]
  691. if drop_action:
  692. self.set_drag_dest_row(path, pos)
  693. break
  694. else:
  695. self.unset_drag_dest_row()
  696. drag_context.drag_status(drop_action, timestamp)
  697. return True
  698. def set_drag_dest_row(self, path, position):
  699. self._widget.set_drag_dest_row(path, position)
  700. def unset_drag_dest_row(self):
  701. self._widget.unset_drag_dest_row()
  702. def on_drag_leave(self, treeview, drag_context, timestamp):
  703. treeview.unset_drag_dest_row()
  704. def on_drag_drop(self, treeview, drag_context, x, y, timestamp):
  705. # prevent the default handler
  706. treeview.emit_stop_by_name('drag-drop')
  707. target = self.find_type(drag_context)
  708. if target == "NONE":
  709. return False
  710. treeview.drag_get_data(drag_context, target, timestamp)
  711. treeview.unset_drag_dest_row()
  712. def on_drag_data_received(self,
  713. treeview, drag_context, x, y, selection, info, timestamp):
  714. # prevent the default handler
  715. treeview.emit_stop_by_name('drag-data-received')
  716. if not self.drag_dest:
  717. return
  718. type = self.find_type(drag_context)
  719. if type == "NONE":
  720. return
  721. if selection.data is None:
  722. return
  723. drop_action = 0
  724. for pos_info in self.calc_positions(x, y):
  725. drop_action = self.drag_dest.validate_drop(self, self.model, type,
  726. drag_context.actions, pos_info[0], pos_info[1])
  727. if drop_action:
  728. self.drag_dest.accept_drop(self, self.model, type,
  729. drag_context.actions, pos_info[0], pos_info[1],
  730. eval(selection.data))
  731. return True
  732. return False
  733. def on_drag_unrealize(self, treeview):
  734. self.drag_button_down = False
  735. def potential_drag_motion(self, treeview, event):
  736. """A motion event has occurred and did not hit a hotspot; start a drag
  737. if applicable.
  738. """
  739. if (self.drag_data and self.drag_button_down and
  740. treeview.drag_check_threshold(self.drag_start_x,
  741. self.drag_start_y, int(event.x), int(event.y))):
  742. self.delaying_press = False
  743. treeview.drag_begin(self._gtk_target_list(self.drag_data.keys()),
  744. self.drag_source.allowed_actions(), 1, event)
  745. @staticmethod
  746. def _gtk_target_list(types):
  747. count = itertools.count()
  748. return [(type, gtk.TARGET_SAME_APP, count.next()) for type in types]
  749. class HotspotTrackingMixin(object):
  750. def __init__(self):
  751. self.hotspot_tracker = None
  752. self.create_signal('hotspot-clicked')
  753. self._hotspot_callback_handles = []
  754. self._connect_hotspot_signals()
  755. self.wrapped_widget_connect('unrealize', self.on_hotspot_unrealize)
  756. def _connect_hotspot_signals(self):
  757. SIGNALS = {
  758. 'row-inserted': self.on_row_inserted,
  759. 'row-deleted': self.on_row_deleted,
  760. 'row-changed': self.on_row_changed,
  761. }
  762. self._hotspot_callback_handles.extend(
  763. weak_connect(self.gtk_model, signal, handler)
  764. for signal, handler in SIGNALS.iteritems())
  765. def on_row_inserted(self, model, path, iter_):
  766. if self.hotspot_tracker:
  767. self.hotspot_tracker.redraw_cell()
  768. self.hotspot_tracker = None
  769. def on_row_deleted(self, model, path):
  770. if self.hotspot_tracker:
  771. self.hotspot_tracker.redraw_cell()
  772. self.hotspot_tracker = None
  773. def on_row_changed(self, model, path, iter_):
  774. if self.hotspot_tracker:
  775. self.hotspot_tracker.update_hit()
  776. def handle_hotspot_hit(self, treeview, event):
  777. """Check whether the event is a hotspot event; return whether handled
  778. here.
  779. """
  780. if self.hotspot_tracker:
  781. return
  782. hotspot_tracker = HotspotTracker(treeview, event)
  783. if hotspot_tracker.hit:
  784. self.hotspot_tracker = hotspot_tracker
  785. hotspot_tracker.redraw_cell()
  786. if hotspot_tracker.is_for_context_menu():
  787. menu = self._popup_context_menu(self.hotspot_tracker.path, event)
  788. if menu:
  789. menu.connect('selection-done',
  790. self._on_hotspot_context_menu_selection_done)
  791. # grab keyboard focus since we handled the event
  792. self.focus()
  793. return True
  794. def _on_hotspot_context_menu_selection_done(self, menu):
  795. # context menu is closed, we won't get the button-release-event in
  796. # this case, but we can unset hotspot tracker here.
  797. if self.hotspot_tracker:
  798. self.hotspot_tracker.redraw_cell()
  799. self.hotspot_tracker = None
  800. def on_hotspot_unrealize(self, treeview):
  801. self.hotspot_tracker = None
  802. def release_on_hotspot(self, event):
  803. """A button_release occurred; return whether it has been handled as a
  804. hotspot hit.
  805. """
  806. hotspot_tracker = self.hotspot_tracker
  807. if hotspot_tracker and event.button == hotspot_tracker.button:
  808. hotspot_tracker.update_position(event)
  809. hotspot_tracker.update_hit()
  810. if (hotspot_tracker.hit and
  811. not hotspot_tracker.is_for_context_menu()):
  812. self.emit('hotspot-clicked', hotspot_tracker.name,
  813. hotspot_tracker.iter)
  814. hotspot_tracker.redraw_cell()
  815. self.hotspot_tracker = None
  816. return True
  817. class ColumnOwnerMixin(object):
  818. """Keeps track of the table's columns - including the list of columns, and
  819. properties that we set for a table but need to apply to each column.
  820. This manages:
  821. columns
  822. attr_map_for_column
  823. gtk_column_to_wrapper
  824. for use throughout tableview.
  825. """
  826. def __init__(self):
  827. self._columns_draggable = False
  828. self._renderer_xpad = self._renderer_ypad = 0
  829. self.columns = []
  830. self.attr_map_for_column = {}
  831. self.gtk_column_to_wrapper = {}
  832. self.create_signal('reallocate-columns') # not emitted on GTK
  833. def remove_column(self, index):
  834. """Remove a column from the display and forget it from the column lists.
  835. """
  836. column = self.columns.pop(index)
  837. del self.attr_map_for_column[column._column]
  838. del self.gtk_column_to_wrapper[column._column]
  839. self._widget.remove_column(column._column)
  840. def get_columns(self):
  841. """Returns the current columns, in order, by title."""
  842. # FIXME: this should probably return column objects, and really should
  843. # not be keeping track of columns by title at all
  844. titles = [column.get_title().decode('utf-8')
  845. for column in self._widget.get_columns()]
  846. return titles
  847. def add_column(self, column):
  848. """Append a column to this table; setup all necessary mappings, and
  849. setup the new column's properties to match the table's settings.
  850. """
  851. self.model.check_new_column(column)
  852. self._widget.append_column(column._column)
  853. self.columns.append(column)
  854. self.attr_map_for_column[column._column] = column.attrs
  855. self.gtk_column_to_wrapper[column._column] = column
  856. self.setup_new_column(column)
  857. def setup_new_column(self, column):
  858. """Apply properties that we keep track of at the table level to a
  859. newly-created column.
  860. """
  861. if self.background_color:
  862. column.renderer._renderer.set_property('cell-background-gdk',
  863. self.background_color)
  864. column._column.set_reorderable(self._columns_draggable)
  865. if column.do_horizontal_padding:
  866. column.renderer._renderer.set_property('xpad', self._renderer_xpad)
  867. column.renderer._renderer.set_property('ypad', self._renderer_ypad)
  868. def set_column_spacing(self, space):
  869. """Set the amount of space between columns."""
  870. self._renderer_xpad = space / 2
  871. for column in self.columns:
  872. if column.do_horizontal_padding:
  873. column.renderer._renderer.set_property('xpad',
  874. self._renderer_xpad)
  875. def set_row_spacing(self, space):
  876. """Set the amount of space between columns."""
  877. self._renderer_ypad = space / 2
  878. for column in self.columns:
  879. column.renderer._renderer.set_property('ypad', self._renderer_ypad)
  880. def set_columns_draggable(self, setting):
  881. """Set the draggability of existing and future columns."""
  882. self._columns_draggable = setting
  883. for column in self.columns:
  884. column._column.set_reorderable(setting)
  885. def set_column_background_color(self):
  886. """Set the background color of existing columns to the table's
  887. background_color.
  888. """
  889. for column in self.columns:
  890. column.renderer._renderer.set_property('cell-background-gdk',
  891. self.background_color)
  892. def set_auto_resizes(self, setting):
  893. # FIXME: to be implemented.
  894. # At this point, GTK somehow does the right thing anyway in terms of
  895. # auto-resizing. I'm not sure exactly what's happening, but I believe
  896. # that if the column widths don't add up to the total width,
  897. # gtk.TreeView allocates extra width for the last column. This works
  898. # well enough for the tab list and item list, since there's only one
  899. # column.
  900. pass
  901. class HoverTrackingMixin(object):
  902. """Handle mouse hover events - tooltips for some cells and hover events for
  903. renderers which support them.
  904. """
  905. def __init__(self):
  906. self.hover_info = None
  907. self.hover_pos = None
  908. if hasattr(self, 'get_tooltip'):
  909. # this should probably be something like self.set_tooltip_source
  910. self._widget.set_property('has-tooltip', True)
  911. self.wrapped_widget_connect('query-tooltip', self.on_tooltip)
  912. self._last_tooltip_place = None
  913. def on_tooltip(self, treeview, x, y, keyboard_mode, tooltip):
  914. # x, y are relative to the entire widget, but we want them to be
  915. # relative to our bin window. The bin window doesn't include things
  916. # like the column headers.
  917. origin = treeview.window.get_origin()
  918. bin_origin = treeview.get_bin_window().get_origin()
  919. x += origin[0] - bin_origin[0]
  920. y += origin[1] - bin_origin[1]
  921. path_info = treeview.get_position_info(x, y)
  922. if path_info is None:
  923. self._last_tooltip_place = None
  924. return False
  925. if (self._last_tooltip_place is not None and
  926. path_info[:2] != self._last_tooltip_place):
  927. # the default GTK behavior is to keep the tooltip in the same
  928. # position, but this is looks bad when we move to a different row.
  929. # So return False once to stop this.
  930. self._last_tooltip_place = None
  931. return False
  932. self._last_tooltip_place = path_info[:2]
  933. iter_ = treeview.get_model().get_iter(path_info.path)
  934. column = self.gtk_column_to_wrapper[path_info.column]
  935. text = self.get_tooltip(iter_, column)
  936. if text is None:
  937. return False
  938. pygtkhacks.set_tooltip_text(tooltip, text)
  939. return True
  940. def _update_hover(self, treeview, event):
  941. old_hover_info, old_hover_pos = self.hover_info, self.hover_pos
  942. path_info = treeview.get_position_info(event.x, event.y)
  943. if (path_info and
  944. self.gtk_column_to_wrapper[path_info.column].renderer.want_hover):
  945. self.hover_info = path_info.path, path_info.column
  946. self.hover_pos = path_info.x, path_info.y
  947. else:
  948. self.hover_info = None
  949. self.hover_pos = None
  950. if (old_hover_info != self.hover_info or
  951. old_hover_pos != self.hover_pos):
  952. if (old_hover_info != self.hover_info and
  953. old_hover_info is not None):
  954. self._redraw_cell(treeview, *old_hover_info)
  955. if self.hover_info is not None:
  956. self._redraw_cell(treeview, *self.hover_info)
  957. class GTKScrollbarOwnerMixin(ScrollbarOwnerMixin):
  958. # XXX this is half a wrapper for TreeViewScrolling. A lot of things will
  959. # become much simpler when we integrate TVS into this
  960. def __init__(self):
  961. ScrollbarOwnerMixin.__init__(self)
  962. # super uses this for postponed scroll_to_iter
  963. # it's a faux-signal from our _widget; this hack is only necessary until
  964. # we integrate TVS
  965. self._widget.scroll_range_changed = (lambda *a:
  966. self.emit('scroll-range-changed'))
  967. def set_scroller(self, scroller):
  968. """Set the Scroller object for this widget, if its ScrolledWindow is
  969. not a direct ancestor of the object. Standard View needs this.
  970. """
  971. self._widget.set_scroller(scroller._widget)
  972. def _set_scroll_position(self, scroll_pos):
  973. self._widget.set_scroll_position(scroll_pos)
  974. def _get_item_area(self, iter_):
  975. return self._widget.get_path_rect(self.get_path(iter_))
  976. @property
  977. def _manually_scrolled(self):
  978. return self._widget.manually_scrolled
  979. @property
  980. def _position_set(self):
  981. return self._widget.position_set
  982. def _get_visible_area(self):
  983. """Return the Rect of the visible area, in tree coords.
  984. get_visible_rect gets this wrong for StandardView, always returning an
  985. origin of (0, 0) - this is because our ScrolledWindow is not our direct
  986. parent.
  987. """
  988. bars = self._widget._scrollbars
  989. x, y = (int(adj.get_value()) for adj in bars)
  990. width, height = (int(adj.get_page_size()) for adj in bars)
  991. if height == 0:
  992. # this happens even after _widget._coords_working
  993. raise WidgetNotReadyError('visible height')
  994. return Rect(x, y, width, height)
  995. def _get_scroll_position(self):
  996. """Get the current position of both scrollbars, to restore later."""
  997. try:
  998. return tuple(int(bar.get_value()) for bar in self._widget._scrollbars)
  999. except WidgetNotReadyError:
  1000. return None
  1001. class TableView(Widget, GTKSelectionOwnerMixin, DNDHandlerMixin,
  1002. HotspotTrackingMixin, ColumnOwnerMixin, HoverTrackingMixin,
  1003. GTKScrollbarOwnerMixin):
  1004. """https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
  1005. draws_selection = True
  1006. def __init__(self, model, custom_headers=False):
  1007. Widget.__init__(self)
  1008. self.set_widget(MiroTreeView())
  1009. self.init_model(model)
  1010. self._setup_colors()
  1011. self.background_color = None
  1012. self.context_menu_callback = None
  1013. self.delaying_press = False
  1014. self._use_custom_headers = False
  1015. self.layout_manager = LayoutManager(self._widget)
  1016. self.height_changed = None # 17178 hack
  1017. self._connect_signals()
  1018. # setting up mixins after general TableView init
  1019. GTKSelectionOwnerMixin.__init__(self)
  1020. DNDHandlerMixin.__init__(self)
  1021. HotspotTrackingMixin.__init__(self)
  1022. ColumnOwnerMixin.__init__(self)
  1023. HoverTrackingMixin.__init__(self)
  1024. GTKScrollbarOwnerMixin.__init__(self)
  1025. if custom_headers:
  1026. self._enable_custom_headers()
  1027. def init_model(self, model):
  1028. self.model = model
  1029. self.model_handler = make_model_handler(model, self._widget)
  1030. @property
  1031. def gtk_model(self):
  1032. return self.model._model
  1033. # FIXME: should implement set_model() and make None a special case.
  1034. def unset_model(self):
  1035. """Disconnect our model from this table view.
  1036. This should be called when you want to destroy a TableView and
  1037. there's a new TableView sharing its model.
  1038. """
  1039. self.model.cleanup()
  1040. self._widget.set_model(None)
  1041. self.model_handler = self.model = None
  1042. def _connect_signals(self):
  1043. self.create_signal('row-expanded')
  1044. self.create_signal('row-collapsed')
  1045. self.create_signal('row-clicked')
  1046. self.create_signal('row-activated')
  1047. self.wrapped_widget_connect('row-activated', self.on_row_activated)
  1048. self.wrapped_widget_connect('row-expanded', self.on_row_expanded)
  1049. self.wrapped_widget_connect('row-collapsed', self.on_row_collapsed)
  1050. self.wrapped_widget_connect('button-press-event', self.on_button_press)
  1051. self.wrapped_widget_connect('button-release-event',
  1052. self.on_button_release)
  1053. self.wrapped_widget_connect('motion-notify-event',
  1054. self.on_motion_notify)
  1055. def set_gradient_highlight(self, gradient):
  1056. # This is just an OS X thing.
  1057. pass
  1058. def set_background_color(self, color):
  1059. self.background_color = self.make_color(color)
  1060. self.modify_style('base', gtk.STATE_NORMAL, self.background_color)
  1061. if not self.draws_selection:
  1062. self.modify_style('base', gtk.STATE_SELECTED,
  1063. self.background_color)
  1064. self.modify_style('base', gtk.STATE_ACTIVE, self.background_color)
  1065. if self.use_custom_style:
  1066. self.set_column_background_color()
  1067. def set_group_lines_enabled(self, enabled):
  1068. """Enable/Disable group lines.
  1069. This only has an effect if our model is an ItemListModel and it has a
  1070. grouping set.
  1071. If group lines are enabled, we will draw a line below the last item in
  1072. the group. Use set_group_li