Merge commit 'origin/future'
[straw.git] / straw / FeedListView.py
blob4367168a1feeb6f52f76455d124083ab9135a5bb
1 """ FeedListView.py
3 Module for displaying the feeds in the Feeds TreeView.
4 """
5 __copyright__ = "Copyright (c) 2002-2005 Free Software Foundation, Inc."
6 __license__ = """
7 Straw is free software; you can redistribute it and/or modify it under the
8 terms of the GNU General Public License as published by the Free Software
9 Foundation; either version 2 of the License, or (at your option) any later
10 version.
12 Straw is distributed in the hope that it will be useful, but WITHOUT ANY
13 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
14 A PARTICULAR PURPOSE. See the GNU General Public License for more details.
16 You should have received a copy of the GNU General Public License along with
17 this program; if not, write to the Free Software Foundation, Inc., 59 Temple
18 Place - Suite 330, Boston, MA 02111-1307, USA. """
20 from Constants import *
21 from error import debug
22 from model import Feed, Item, Category
23 import Config
24 import FeedManager
25 import MVP
26 import categoryproperties
27 import feedproperties
28 import gobject
29 import gtk
30 import helpers
31 import os, copy, locale, logging
33 class Column:
34 pixbuf, name, foreground, unread, object, editable = range(6)
36 _tmp_widget = gtk.Label()
38 class TreeViewNode(object):
39 def __init__(self, node, store):
40 self.node = node
41 self.store = store
43 self.node.connect("notify", self._on_unread_count_changed)
45 def _on_unread_count_changed(self, node, delta):
46 self.refresh()
48 def refresh(self):
49 iter = self.iter
51 if not iter:
52 return
54 self.store.set(iter, Column.pixbuf, self.pixbuf)
55 self.store.set(iter, Column.unread, self.node.unread_count)
56 self.store.set(iter, Column.name, self.title)
58 @property
59 def title(self):
60 ''' The title of the node be it a category or a feed '''
62 if self.node.type == "C":
63 title = self.node.name
64 elif self.node.type == "F":
65 title = self.node.title
67 title = helpers.pango_escape(title)
69 if hasattr(self.node, "status") and (self.node.status & FS_UPDATING):
70 if title:
71 title = "<i>" + title + "</i>"
73 return title
75 @property
76 def unread_count(self):
77 ''' The title of the node be it a category or a feed '''
78 return self.node.unread_count
80 @property
81 def pixbuf(self):
82 ''' gets the pixbuf to display according to the status of the feed '''
83 global _tmp_widget
85 if isinstance(self.node, Feed):
86 if self.node.status == FS_ERROR:
87 return _tmp_widget.render_icon(gtk.STOCK_CANCEL, gtk.ICON_SIZE_MENU)
88 else:
89 return _tmp_widget.render_icon(gtk.STOCK_FILE, gtk.ICON_SIZE_MENU)
90 else:
91 return _tmp_widget.render_icon(gtk.STOCK_DIRECTORY, gtk.ICON_SIZE_MENU)
93 @property
94 def path_list(self):
95 path = []
96 node = copy.copy(self.node)
98 while node:
99 path.append(node.norder)
100 node = copy.copy(node.parent)
102 path.pop() # We don't need path to "root" category here since it's not in the tree view.
103 path.reverse()
105 return path
107 @property
109 def path(self):
110 path = self.path_list
112 if len(path) == 0:
113 return None
114 else:
115 return ":".join(map(str, path))
117 @property
118 def iter(self):
119 path = self.path
121 if path == None:
122 return None
123 else:
124 try:
125 return self.store.get_iter_from_string(path)
126 except:
127 return None
129 @property
131 def parent_path(self):
132 path = self.path_list
133 path.pop()
135 if len(path) == 0:
136 return None
137 else:
138 return ":".join(map(str, path))
140 @property
141 def parent_iter(self):
142 path = self.parent_path
144 if path == None:
145 return None
146 else:
147 return self.store.get_iter_from_string(path)
149 class FeedListModel:
150 ''' The model for the feed list view '''
152 def __init__(self):
153 self.refresh_tree()
154 self._init_signals()
156 def refresh_tree(self):
157 self.appmodel = FeedManager.get_model()
159 self._prepare_store()
160 self._prepare_model()
162 self._populate_tree(1, None, [])
164 def _init_signals(self):
165 FeedManager._get_instance().connect("feed-added", self._on_node_added)
166 FeedManager._get_instance().connect("feed-status-changed", self._on_feed_status_changed)
167 FeedManager._get_instance().connect("category-added", self._on_node_added)
169 def _prepare_model(self):
170 self.tv_nodes = dict([(node.id, TreeViewNode(node, self.store)) for node in self.appmodel.values()])
172 def _prepare_store(self):
173 self.store = gtk.TreeStore(gtk.gdk.Pixbuf, str, str, str, gobject.TYPE_PYOBJECT, bool)
175 def _populate_tree(self, parent_id, parent_iter, done):
176 for node in self.tv_nodes[parent_id].node.children:
177 tv_node = self.tv_nodes[node.id]
179 if node.type == "F":
180 self._create_row(tv_node)
181 tv_node.store = self.store
182 elif node.type == "C":
183 current_parent = self._create_row(tv_node)
184 tv_node.store = self.store
186 if self.tv_nodes.has_key(node.id):
187 self._populate_tree(node.id, current_parent, done)
189 def _create_row(self, node, editable = False):
190 return self.store.append(node.parent_iter, [node.pixbuf,
191 node.title,
192 'black',
193 node.unread_count,
194 node, editable])
196 def _on_node_added(self, src, node):
197 tv_node = TreeViewNode(node, self.store)
198 self.add_node(tv_node)
199 self._create_row(tv_node)
201 def _on_feed_status_changed(self, src, feed):
202 self.tv_nodes[feed.id].refresh()
204 def add_node(self, tv_node):
205 self.tv_nodes[tv_node.node.id] = tv_node
207 @property
208 def model(self):
209 return self.store
211 def search(self, rows, func, data):
212 if not rows: return None
213 for row in rows:
214 if func(row, data):
215 return row
216 result = self.search(row.iterchildren(), func, data)
217 if result: return result
218 return None
220 class FeedsView(MVP.WidgetView):
221 def _initialize(self):
222 self._widget.set_search_column(Column.name)
224 # pixbuf column
225 column = gtk.TreeViewColumn()
226 unread_renderer = gtk.CellRendererText()
227 column.pack_start(unread_renderer, False)
228 column.set_attributes(unread_renderer, text = Column.unread)
230 status_renderer = gtk.CellRendererPixbuf()
231 column.pack_start(status_renderer, False)
232 column.set_attributes(status_renderer, pixbuf = Column.pixbuf)
234 # feed title renderer
235 title_renderer = gtk.CellRendererText()
237 title_renderer.connect("edited", self.on_node_edit_title_edited)
238 title_renderer.connect("editing-canceled", self.on_node_edit_title_canceled)
240 #title_renderer.set_property('editable', True)
241 column.pack_start(title_renderer, False)
242 column.set_attributes(title_renderer,
243 foreground=Column.foreground,
244 markup=Column.name,
245 editable=Column.editable) #, weight=Column.BOLD)
247 self._widget.append_column(column)
249 selection = self._widget.get_selection()
250 selection.set_mode(gtk.SELECTION_MULTIPLE)
252 self._widget.connect("button_press_event", self._on_button_press_event)
253 self._widget.connect("popup-menu", self._on_popup_menu)
255 uifactory = helpers.UIFactory('FeedListActions')
256 action = uifactory.get_action('/feedlist_popup/refresh')
257 action.connect('activate', self.on_menu_poll_selected_activate)
258 action = uifactory.get_action('/feedlist_popup/add_child')
259 action.connect('activate', self.on_menu_add_child_activate)
260 self.mark_all_as_read_action = uifactory.get_action('/feedlist_popup/mark_as_read')
261 self.mark_all_as_read_action.connect('activate', self.on_menu_mark_all_as_read_activate)
262 action = uifactory.get_action('/feedlist_popup/stop_refresh')
263 action.connect('activate', self.on_menu_stop_poll_selected_activate)
264 action = uifactory.get_action('/feedlist_popup/remove')
265 action.connect('activate', self.on_remove_selected_feed)
266 action = uifactory.get_action('/feedlist_popup/properties')
267 action.connect('activate', self.on_display_properties_feed)
268 self.popup = uifactory.get_popup('/feedlist_popup')
270 treeview = self._widget
272 treeview.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, [("drop_yes", 0, 0)], gtk.gdk.ACTION_MOVE)
273 treeview.enable_model_drag_dest([("drop_yes", 0, 0)], gtk.gdk.ACTION_MOVE)
274 treeview.connect("drag_data_received", self._on_dragdata_received)
275 treeview.connect("drag_motion", self._on_drag_motion)
277 def _on_drag_motion(self, treeview, drag_context, x, y, eventtime):
278 temp = treeview.get_dest_row_at_pos(x, y)
280 if not temp:
281 return
283 model = treeview.get_model()
284 drop_path, drop_position = temp
286 # FIXME: Here we use only first selected node of possibly multiple
287 # selected. See fixme comment in self._on_dragdata_received.
288 source_node = [tv_node for tv_node in self.selected()][0]
290 drop_node = model[drop_path][Column.object]
291 source_path = source_node.path_list
293 sane_drop_path = self._check_drop_path(model, source_path, drop_path)
295 is_drop_into = drop_position == gtk.TREE_VIEW_DROP_INTO_OR_BEFORE or \
296 drop_position == gtk.TREE_VIEW_DROP_INTO_OR_AFTER
298 can_drop_into = drop_node.node.is_parent()
300 if sane_drop_path and ((is_drop_into and can_drop_into) or not is_drop_into):
301 treeview.enable_model_drag_dest([("drop_yes", 0, 0)], gtk.gdk.ACTION_MOVE)
302 else:
303 treeview.enable_model_drag_dest([("drop_no", 0, 0)], gtk.gdk.ACTION_MOVE)
305 def _on_dragdata_received(self, treeview, drag_context, x, y, selection, info, eventtime):
306 model, pathlist = treeview.get_selection().get_selected_rows()
308 if len(pathlist) > 1:
309 # FIXME: Maybe we want to support drag and drop for multiple rows?
310 # For now it's not worth it while there are other things to do.
311 drag_context.finish(False, False, eventtime)
312 return
314 source_path = pathlist[0]
315 source_iter = model.get_iter(source_path)
317 temp = treeview.get_dest_row_at_pos(x, y)
319 if temp != None:
320 drop_path, drop_pos = temp
321 else:
322 drop_path, drop_pos = (len(model) - 1,), gtk.TREE_VIEW_DROP_AFTER
324 effective_drop_path = self._calculate_effective_drop_path(source_path, drop_path, drop_pos)
326 if source_path == effective_drop_path:
327 drag_context.finish(False, False, eventtime)
328 return
330 drop_iter = model.get_iter(drop_path)
332 if not self._check_drop_path(model, source_path, drop_path):
333 drag_context.finish(False, False, eventtime)
334 return
336 node = model[source_path][Column.object].node
337 self._iter_copy(model, source_iter, drop_iter, drop_pos)
339 drag_context.finish(True, True, eventtime)
340 FeedManager.move_node(node, effective_drop_path)
342 def _check_drop_path(self, model, source_path, drop_path):
344 Verifies if a drop path is not within the subtree of source path so that
345 we can disallow dropping parent into its own subtree etc. using this check.
348 return list(drop_path[0:len(source_path)]) != list(source_path)
350 def _iter_copy(self, model, iter_to_copy, target_iter, pos):
352 Recursively copies GTK TreeView iters from source to target using
353 GTK relative data about drag and drop operation.
356 path = model.get_path(iter_to_copy)
358 if (pos == gtk.TREE_VIEW_DROP_INTO_OR_BEFORE) or (pos == gtk.TREE_VIEW_DROP_INTO_OR_AFTER):
359 new_iter = model.prepend(target_iter, model[path])
360 elif pos == gtk.TREE_VIEW_DROP_BEFORE:
361 new_iter = model.insert_before(None, target_iter, model[path])
362 elif pos == gtk.TREE_VIEW_DROP_AFTER:
363 new_iter = model.insert_after(None, target_iter, model[path])
365 n = model.iter_n_children(iter_to_copy)
367 for i in range(n):
368 next_iter_to_copy = model.iter_nth_child(iter_to_copy, n - i - 1)
369 self._iter_copy(model, next_iter_to_copy, new_iter, gtk.TREE_VIEW_DROP_INTO_OR_BEFORE)
371 def _calculate_effective_drop_path(self, source_path, drop_path, drop_pos):
373 Calculate effective absolute drop path given drop_pos and source/destination
374 of drag and drop operation. GTK uses relative terms for describing drop
375 destination (after/before/into etc.) while we prefer absolute drop path
376 and we can take care of reordering ourselves.
379 result = list(drop_path)
380 same_level = len(source_path) == len(drop_path) and source_path[:-1] == drop_path[:-1]
382 if drop_pos == gtk.TREE_VIEW_DROP_INTO_OR_BEFORE:
383 result.append(0)
384 elif drop_pos == gtk.TREE_VIEW_DROP_INTO_OR_AFTER:
385 result.append(0)
386 elif drop_pos == gtk.TREE_VIEW_DROP_BEFORE:
387 if not same_level or (same_level and source_path[-1] < drop_path[-1]):
388 if result[-1] > 0:
389 result[-1] -= 1
390 elif drop_pos == gtk.TREE_VIEW_DROP_AFTER:
391 if not same_level or (same_level and source_path[-1] > drop_path[-1]):
392 result[-1] += 1
394 return tuple(result)
396 def _model_set(self):
397 self._widget.set_model(self._model.model)
399 def add_selection_changed_listener(self, listener):
400 selection = self._widget.get_selection()
401 selection.connect('changed', listener.feedlist_selection_changed, Column.object)
403 def add_mark_all_as_read_listener(self, listener):
404 self.mark_all_as_read_action.connect('activate', listener.on_mark_all_as_read)
406 def _on_popup_menu(self, treeview, *args):
407 self.popup.popup(None, None, None, 0, 0)
409 def _on_button_press_event(self, treeview, event):
410 retval = 0
412 if event.button == 3:
413 x = int(event.x)
414 y = int(event.y)
415 time = gtk.get_current_event_time()
416 path = treeview.get_path_at_pos(x, y)
418 if path is None:
419 return 1
421 path = path[0]
422 self.node_at_popup = self.model.store[path][Column.object]
423 treeview.grab_focus()
425 if self.selected_count() < 2:
426 selection = treeview.get_selection()
427 selection.unselect_all()
428 selection.select_path(path)
430 self.popup.popup(None, None, None, event.button, time)
431 retval = 1
433 return retval
435 def get_selected_node(self):
436 nodes = [node for node in self.selected()]
438 if len(nodes) > 0:
439 return nodes[0].node
440 else:
441 return None
443 def get_expanded_nodes(self):
444 expanded = []
446 def add(treeview, path, expanded):
447 node = treeview.get_model()[path][Column.object].node
448 expanded.append(node)
450 self._widget.map_expanded_rows(add, expanded)
452 return expanded
454 def expand_nodes(self, nodes):
455 for node_id in nodes:
456 if node_id in self.model.tv_nodes:
457 path = self.model.tv_nodes[node_id].path
458 self._widget.expand_row(path, False)
460 def select_node(self, id):
461 if not id in self.model.tv_nodes:
462 return
464 path = self.model.tv_nodes[id].path
466 if not path:
467 return
469 selection = self._widget.get_selection()
470 selection.unselect_all()
471 self._widget.expand_to_path(path)
472 selection.select_path(path)
473 self._widget.grab_focus()
475 def selected_count(self):
476 selection = self._widget.get_selection()
477 pathlist = selection.get_selected_rows()[1]
478 return len(pathlist)
480 def selected(self):
481 selection = self._widget.get_selection()
482 (model, pathlist) = selection.get_selected_rows()
483 nodes = [model[path][Column.object] for path in pathlist]
485 for tv_node in nodes:
486 yield tv_node
488 def foreach_selected(self, func):
489 selection = self._widget.get_selection()
490 (model, pathlist) = selection.get_selected_rows()
491 iters = [model.get_iter(path) for path in pathlist]
492 try:
493 for treeiter in iters:
494 object = model.get_value(treeiter, Column.object)
495 func(object, model, treeiter)
496 except TypeError, te:
497 logging.exception(te)
499 def on_menu_add_child_activate(self, *args):
500 node = self.node_at_popup.node
502 if not self.node_at_popup.node.is_parent():
503 node = node.parent
505 self.begin_add_category(node)
507 def begin_add_category(self, node):
508 category = Category()
509 category.parent = node
510 category.norder = len(node.children)
511 self.new_child = TreeViewNode(category, self.model.store)
512 iter = self.model._create_row(self.new_child, editable = True)
513 path = self.model.store.get_path(iter)
514 column = self._widget.get_column(0)
516 parent_path = self.new_child.parent_path
518 if parent_path:
519 self._widget.expand_row(parent_path, False)
521 self._widget.set_cursor_on_cell(path, focus_column = column, start_editing = True)
523 def on_menu_poll_selected_activate(self, *args):
524 """config = Config.get_instance()
526 if config.offline: #XXX
527 config.offline = not config.offline"""
529 FeedManager.update_nodes([node.node for node in self.selected()])
531 def on_menu_stop_poll_selected_activate(self, *args):
532 self.foreach_selected(lambda o,*args: o.feed.router.stop_polling())
534 def on_menu_mark_all_as_read_activate(self, *args):
535 self.foreach_selected(lambda o, *args: o.node.mark_items_as_read())
537 def on_remove_selected_feed(self, *args):
538 nodes = [tv_node for tv_node in self.selected()]
540 FeedManager.delete_nodes([tv_node.node.id for tv_node in nodes])
542 for node in nodes:
543 iter = node.iter
545 if iter:
546 self.model.store.remove(iter)
548 def on_node_edit_title_canceled(self, cellrenderer):
549 if self.new_child:
550 debug("on_node_edit_title_canceled (new_child), removing %s" % str(self.new_child.path))
551 self.model.store.remove(self.new_child.iter)
552 self.new_child = None
554 def on_node_edit_title_edited(self, cellrenderertext, path, new_text):
555 if len(new_text) > 0:
556 self.new_child.node.name = new_text
557 FeedManager.save_category(self.new_child.node)
559 self.model.store.remove(self.new_child.iter)
560 self.new_child = None
562 def on_display_properties_feed(self, *args):
563 selected_tv_node = [tv_node for tv_node in self.selected()][0]
564 self._presenter.show_feed_information(selected_tv_node.node)
566 def add_category(self):
567 self.begin_add_category(self._model.tv_nodes[1].node)
569 def select_first_feed(self):
570 selection = self._widget.get_selection()
571 (model, pathlist) = selection.get_selected_rows()
572 treeiter = model.get_iter_first()
573 if not treeiter or not model.iter_is_valid(treeiter):
574 return False
575 self.set_cursor(treeiter)
576 return True
578 def select_next_feed(self, with_unread=False):
579 ''' Scrolls to the next feed in the feed list
581 If there is no selection, selects the first feed. If multiple feeds
582 are selected, selects the feed after the last selected feed.
584 If unread is True, selects the next unread with unread items.
586 If the selection next-to-be is a category, go to the iter its first
587 child. If current selection is a child, then go to (parent + 1),
588 provided that (parent + 1) is not a category.
590 has_unread = False
591 def next(model, current):
592 treeiter = model.iter_next(current)
593 if not treeiter: return False
594 if model.iter_depth(current): next(model, model.iter_parent(current))
595 path = model.get_path(treeiter)
596 if with_unread and model[path][Column.unread] < 1:
597 next(model, current)
598 self.set_cursor(treeiter)
599 return True
600 selection = self._widget.get_selection()
601 (model, pathlist) = selection.get_selected_rows()
602 iters = [model.get_iter(path) for path in pathlist]
603 try:
604 current = iters.pop()
605 if model.iter_has_child(current):
606 iterchild = model.iter_children(current)
607 # make the row visible
608 path = model.get_path(iterchild)
609 for i in range(len(path)):
610 self._widget.expand_row(path[:i+1], False)
611 # select his first born child
612 if with_unread and model[path][Column.unread] > 0:
613 self.set_cursor(iterchild)
614 has_unread = True
615 else:
616 has_unread = next(model, current)
617 has_unread = next(model,current)
618 except IndexError:
619 self.set_cursor(model.get_iter_first())
620 has_unread = True
621 return has_unread
623 def select_previous_feed(self):
624 ''' Scrolls to the previous feed in the feed list.
626 If there is no selection, selects the first feed. If there's multiple
627 selection, selects the feed before the first selected feed.
629 If the previous selection is a category, select the last node in that
630 category. If the current selection is a child, then go to (parent -
631 1). If parent is the first feed, wrap and select the last feed or
632 category in the list.
634 def previous(model, current):
635 path = model.get_path(current)
636 treerow = model[path[-1]-1]
637 self.set_cursor(treerow.iter)
638 selection = self._widget.get_selection()
639 (model, pathlist) = selection.get_selected_rows()
640 iters = [model.get_iter(path) for path in pathlist]
641 try:
642 current_first = iters.pop(0)
643 if model.iter_has_child(current_first):
644 children = model.iter_n_children(current_first)
645 treeiter = model.iter_nth_child(children - 1)
646 self.set_cursor(treeiter)
647 return
648 previous(model, current_first)
649 except IndexError:
650 self.set_cursor(model.get_iter_first())
651 return
653 def set_cursor(self, treeiter, col_id=None, edit=False):
654 if not treeiter:
655 return
657 column = None
658 path = self._model.model.get_path(treeiter)
660 if col_id:
661 column = self._widget.get_column(col_id)
663 self._widget.set_cursor(path, column, edit)
664 self._widget.scroll_to_cell(path, column)
665 self._widget.grab_focus()
667 class FeedsPresenter(MVP.BasicPresenter):
668 def _initialize(self):
669 self.model = FeedListModel()
671 def store_state(self):
672 node = self.view.get_selected_node()
673 id = -1
675 if node:
676 id = node.id
678 Config.set(OPTION_LAST_SELECTED_NODE, id)
679 Config.set(OPTION_LAST_EXPANDED_NODES, ",".join([str(node.id) for node in self.view.get_expanded_nodes()]))
681 def restore_state(self):
682 id = Config.get(OPTION_LAST_SELECTED_NODE)
684 if id != -1:
685 self.view.select_node(id)
687 expanded = Config.get(OPTION_LAST_EXPANDED_NODES)
689 if expanded:
690 try:
691 expanded = map(int, expanded.split(","))
692 except ValueError:
693 expanded = []
695 self.view.expand_nodes(expanded)
697 def add_category(self):
698 self.view.add_category()
700 def select_first_feed(self):
701 return self.view.select_first_feed()
703 def select_next_feed(self, with_unread=False):
704 return self.view.select_next_feed(with_unread)
706 def select_previous_feed(self):
707 return self.view.select_previous_feed()
709 def show_feed_information(self, node):
710 if node.type == "C":
711 properties = categoryproperties
712 elif node.type == "F":
713 properties = feedproperties
715 properties.show(node)