Implemented Fetcher job, refactored JobManager, code cleanup.
[straw.git] / straw / FeedListView.py
blob0e61ce1d08bc9cd3e305c7b473e6851cfe8773a3
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 error import debug
21 from model import Feed, Item, Category
22 import Config
23 import FeedManager
24 import MVP
25 import categoryproperties
26 import feedproperties
27 import gobject
28 import gtk
29 import helpers
30 import os, copy, locale, logging
31 import pango
32 import straw
34 class Column:
35 pixbuf, name, foreground, unread, object, editable = range(6)
37 _tmp_widget = gtk.Label()
39 class TreeViewNode(object):
40 def __init__(self, node, store):
41 self.node = node
42 self.store = store
44 self.setup_node()
46 def setup_node(self):
47 self.node.connect("notify", self.obj_changed)
49 def obj_changed(self, obj, property):
50 #import threading
51 #print "obj_changed: %s" % threading.currentThread()
52 #debug("obj %d changed: property.name = %s, self.path = %s, self.store[path].id = %s" % (obj.id, property.name, str(self.path), str(self.store[self.path][Column.object].node.id)))
53 #debug("obj %d changed: property.name = %s, self.path = %s, self.store[path].id = %s" % (obj.id, property.name, str(self.path), str(self.store[self.path][Column.object].node.id)))
55 #self.store.set(self.iter, Column.pixbuf, self.pixbuf)
57 if property.name == "unread-count":
58 #debug("obj %d changed: property.name = %s, self.path = %s, self.store[path].id = %s" % (obj.id, property.name, str(self.path), str(self.store[self.path][Column.object].node.id)))
59 iter = self.iter
61 debug("setting %d unread_count = %d, self.path = %s" % (obj.id, self.unread_count, str(self.path)))
63 if iter:
64 self.store.set(self.iter, 3, self.node.unread_count)
65 elif property.name == "status":
66 if (self.node.status & straw.FS_UPDATING) > 0:
67 title = self.store.get_value(self.iter, 1)
69 if title:
70 self.store.set(self.iter, 1, "<i>" + title + "</i>")
71 else:
72 title = self.node.title
73 self.store.set(self.iter, 1, title)
75 @property
76 def title(self):
77 ''' The title of the node be it a category or a feed '''
78 if self.node.type == "C":
79 return self.node.name
80 elif self.node.type == "F":
81 return self.node.title
83 @property
84 def unread_count(self):
85 ''' The title of the node be it a category or a feed '''
86 return self.node.unread_count
88 @property
89 def pixbuf(self):
90 ''' gets the pixbuf to display according to the status of the feed '''
91 global _tmp_widget
93 if isinstance(self.node, Feed):
94 if self.node.status == straw.FS_ERROR:
95 return _tmp_widget.render_icon(gtk.STOCK_CANCEL, gtk.ICON_SIZE_MENU)
96 else:
97 return _tmp_widget.render_icon(gtk.STOCK_FILE, gtk.ICON_SIZE_MENU)
98 else:
99 return _tmp_widget.render_icon(gtk.STOCK_DIRECTORY, gtk.ICON_SIZE_MENU)
101 @property
102 def path_list(self):
103 path = []
105 node = copy.copy(self.node)
107 while node:
108 path.append(node.norder)
109 node = copy.copy(node.parent)
111 path.pop() # We don't need path to "root" category here since it's not in the tree view.
112 path.reverse()
114 return path
116 @property
117 def path(self):
118 path = self.path_list
120 if len(path) == 0:
121 return None
122 else:
123 return ":".join(map(str, path))
125 @property
126 def iter(self):
127 path = self.path
129 if path == None:
130 return None
131 else:
132 try:
133 return self.store.get_iter_from_string(path)
134 except:
135 return None
137 @property
138 def parent_path(self):
139 path = self.path_list
141 path.pop()
143 if len(path) == 0:
144 return None
145 else:
146 return ":".join(map(str, path))
148 @property
149 def parent_iter(self):
150 path = self.parent_path
151 #print path
152 if path == None:
153 return None
154 else:
155 return self.store.get_iter_from_string(path)
157 class FeedListModel:
158 ''' The model for the feed list view '''
160 def __init__(self):
161 self.refresh_tree()
162 self._init_signals()
164 def refresh_tree(self):
165 self.appmodel = FeedManager.get_model()
167 self._prepare_store()
168 self._prepare_model()
170 self._populate_tree(1, None, [])
172 def _init_signals(self):
173 FeedManager._get_instance().connect("feed-added", self.node_added_cb)
174 FeedManager._get_instance().connect("category-added", self.node_added_cb)
176 def _prepare_model(self):
177 self.tv_nodes = dict([(node.id, TreeViewNode(node, self.store)) for node in self.appmodel.values()])
179 def _prepare_store(self):
180 self.store = gtk.TreeStore(gtk.gdk.Pixbuf, str, str, str, gobject.TYPE_PYOBJECT, bool)
182 def _populate_tree(self, parent_id, parent_iter, done):
183 for node in self.tv_nodes[parent_id].node.children:
184 tv_node = self.tv_nodes[node.id]
186 if node.type == "F":
187 self._create_row(tv_node)
188 tv_node.store = self.store
189 elif node.type == "C":
190 current_parent = self._create_row(tv_node)
191 tv_node.store = self.store
193 if self.tv_nodes.has_key(node.id):
194 self._populate_tree(node.id, current_parent, done)
196 def _create_row(self, node, editable = False):
197 return self.store.append(node.parent_iter, [node.pixbuf,
198 helpers.pango_escape(node.title),
199 'black',
200 node.unread_count,
201 node, editable])
203 def add_node(self, tv_node):
204 self.tv_nodes[tv_node.node.id] = tv_node
206 def node_added_cb(self, src, node):
207 tv_node = TreeViewNode(node, self.store)
208 self.add_node(tv_node)
209 self._create_row(tv_node)
211 @property
212 def model(self):
213 return self.store
215 def search(self, rows, func, data):
216 if not rows: return None
217 for row in rows:
218 if func(row, data):
219 return row
220 result = self.search(row.iterchildren(), func, data)
221 if result: return result
222 return None
224 class FeedsView(MVP.WidgetView):
225 def _initialize(self):
226 self._widget.set_search_column(Column.name)
228 # pixbuf column
229 column = gtk.TreeViewColumn()
230 unread_renderer = gtk.CellRendererText()
231 column.pack_start(unread_renderer, False)
232 column.set_attributes(unread_renderer, text = Column.unread)
234 status_renderer = gtk.CellRendererPixbuf()
235 column.pack_start(status_renderer, False)
236 column.set_attributes(status_renderer, pixbuf = Column.pixbuf)
238 # feed title renderer
239 title_renderer = gtk.CellRendererText()
241 title_renderer.connect("edited", self.on_node_edit_title_edited)
242 title_renderer.connect("editing-canceled", self.on_node_edit_title_canceled)
244 #title_renderer.set_property('editable', True)
245 column.pack_start(title_renderer, False)
246 column.set_attributes(title_renderer,
247 foreground=Column.foreground,
248 markup=Column.name,
249 editable=Column.editable) #, weight=Column.BOLD)
251 self._widget.append_column(column)
253 selection = self._widget.get_selection()
254 selection.set_mode(gtk.SELECTION_MULTIPLE)
256 self._widget.connect("button_press_event", self._on_button_press_event)
257 self._widget.connect("popup-menu", self._on_popup_menu)
259 uifactory = helpers.UIFactory('FeedListActions')
260 action = uifactory.get_action('/feedlist_popup/refresh')
261 action.connect('activate', self.on_menu_poll_selected_activate)
262 action = uifactory.get_action('/feedlist_popup/add_child')
263 action.connect('activate', self.on_menu_add_child_activate)
264 action = uifactory.get_action('/feedlist_popup/mark_as_read')
265 action.connect('activate', self.on_menu_mark_all_as_read_activate)
266 action = uifactory.get_action('/feedlist_popup/stop_refresh')
267 action.connect('activate', self.on_menu_stop_poll_selected_activate)
268 action = uifactory.get_action('/feedlist_popup/remove')
269 action.connect('activate', self.on_remove_selected_feed)
270 action = uifactory.get_action('/feedlist_popup/properties')
271 action.connect('activate', self.on_display_properties_feed)
272 self.popup = uifactory.get_popup('/feedlist_popup')
274 treeview = self._widget
276 treeview.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, [("drop_yes", 0, 0)], gtk.gdk.ACTION_MOVE)
277 treeview.enable_model_drag_dest([("drop_yes", 0, 0)], gtk.gdk.ACTION_MOVE)
278 treeview.connect("drag_data_received", self._on_dragdata_received)
279 treeview.connect("drag_motion", self._on_drag_motion)
281 def _on_drag_motion(self, treeview, drag_context, x, y, eventtime):
282 temp = treeview.get_dest_row_at_pos(x, y)
284 if not temp:
285 return
287 model = treeview.get_model()
288 drop_path, drop_position = temp
290 # FIXME: Here we use only first selected node of possibly multiple
291 # selected. See fixme comment in self._on_dragdata_received.
292 source_node = [tv_node for tv_node in self.selected()][0]
294 drop_node = model[drop_path][Column.object]
295 source_path = source_node.path_list
297 sane_drop_path = self._check_drop_path(model, source_path, drop_path)
299 is_drop_into = drop_position == gtk.TREE_VIEW_DROP_INTO_OR_BEFORE or \
300 drop_position == gtk.TREE_VIEW_DROP_INTO_OR_AFTER
302 can_drop_into = drop_node.node.is_parent()
304 print sane_drop_path
306 if sane_drop_path and ((is_drop_into and can_drop_into) or not is_drop_into):
307 treeview.enable_model_drag_dest([("drop_yes", 0, 0)], gtk.gdk.ACTION_MOVE)
308 else:
309 treeview.enable_model_drag_dest([("drop_no", 0, 0)], gtk.gdk.ACTION_MOVE)
311 def _on_dragdata_received(self, treeview, drag_context, x, y, selection, info, eventtime):
312 model, pathlist = treeview.get_selection().get_selected_rows()
314 if len(pathlist) > 1:
315 # FIXME: Maybe we want to support drag and drop for multiple rows?
316 # For now it's not worth it while there are other things to do.
317 drag_context.finish(False, False, eventtime)
318 return
320 source_path = pathlist[0]
321 source_iter = model.get_iter(source_path)
323 temp = treeview.get_dest_row_at_pos(x, y)
325 if temp != None:
326 drop_path, drop_pos = temp
327 else:
328 drop_path, drop_pos = (len(model) - 1,), gtk.TREE_VIEW_DROP_AFTER
330 effective_drop_path = self._calculate_effective_drop_path(source_path, drop_path, drop_pos)
332 if source_path == effective_drop_path:
333 drag_context.finish(False, False, eventtime)
334 return
336 drop_iter = model.get_iter(drop_path)
338 if not self._check_drop_path(model, source_path, drop_path):
339 drag_context.finish(False, False, eventtime)
340 return
342 node = model[source_path][Column.object].node
343 self._iter_copy(model, source_iter, drop_iter, drop_pos)
345 drag_context.finish(True, True, eventtime)
346 FeedManager.move_node(node, effective_drop_path)
348 def _check_drop_path(self, model, source_path, drop_path):
350 Verifies if a drop path is not within the subtree of source path so that
351 we can disallow dropping parent into its own subtree etc. using this check.
354 return list(drop_path[0:len(source_path)]) != list(source_path)
356 def _iter_copy(self, model, iter_to_copy, target_iter, pos):
358 Recursively copies GTK TreeView iters from source to target using
359 GTK relative data about drag and drop operation.
362 path = model.get_path(iter_to_copy)
364 if (pos == gtk.TREE_VIEW_DROP_INTO_OR_BEFORE) or (pos == gtk.TREE_VIEW_DROP_INTO_OR_AFTER):
365 new_iter = model.prepend(target_iter, model[path])
366 elif pos == gtk.TREE_VIEW_DROP_BEFORE:
367 new_iter = model.insert_before(None, target_iter, model[path])
368 elif pos == gtk.TREE_VIEW_DROP_AFTER:
369 new_iter = model.insert_after(None, target_iter, model[path])
371 n = model.iter_n_children(iter_to_copy)
373 for i in range(n):
374 next_iter_to_copy = model.iter_nth_child(iter_to_copy, n - i - 1)
375 self._iter_copy(model, next_iter_to_copy, new_iter, gtk.TREE_VIEW_DROP_INTO_OR_BEFORE)
377 def _calculate_effective_drop_path(self, source_path, drop_path, drop_pos):
379 Calculate effective absolute drop path given drop_pos and source/destination
380 of drag and drop operation. GTK uses relative terms for describing drop
381 destination (after/before/into etc.) while we prefer absolute drop path
382 and we can take care of reordering ourselves.
385 result = list(drop_path)
386 same_level = len(source_path) == len(drop_path) and source_path[:-1] == drop_path[:-1]
388 if drop_pos == gtk.TREE_VIEW_DROP_INTO_OR_BEFORE:
389 result.append(0)
390 elif drop_pos == gtk.TREE_VIEW_DROP_INTO_OR_AFTER:
391 result.append(0)
392 elif drop_pos == gtk.TREE_VIEW_DROP_BEFORE:
393 if not same_level or (same_level and source_path[-1] < drop_path[-1]):
394 if result[-1] > 0:
395 result[-1] -= 1
396 elif drop_pos == gtk.TREE_VIEW_DROP_AFTER:
397 if not same_level or (same_level and source_path[-1] > drop_path[-1]):
398 result[-1] += 1
400 return tuple(result)
402 def _model_set(self):
403 self._widget.set_model(self._model.model)
405 def add_selection_changed_listener(self, listener):
406 selection = self._widget.get_selection()
407 selection.connect('changed', listener.feedlist_selection_changed, Column.object)
409 def _on_popup_menu(self, treeview, *args):
410 self.popup.popup(None, None, None, 0, 0)
412 def _on_button_press_event(self, treeview, event):
413 retval = 0
415 if event.button == 3:
416 x = int(event.x)
417 y = int(event.y)
418 time = gtk.get_current_event_time()
419 path = treeview.get_path_at_pos(x, y)
421 if path is None:
422 return 1
424 path = path[0]
425 self.node_at_popup = self.model.store[path][Column.object]
426 treeview.grab_focus()
428 if self.selected_count() < 2:
429 selection = treeview.get_selection()
430 selection.unselect_all()
431 selection.select_path(path)
433 self.popup.popup(None, None, None, event.button, time)
434 retval = 1
436 return retval
438 def selected_count(self):
439 selection = self._widget.get_selection()
440 pathlist = selection.get_selected_rows()[1]
441 return len(pathlist)
443 def selected(self):
444 selection = self._widget.get_selection()
445 (model, pathlist) = selection.get_selected_rows()
446 nodes = [model[path][Column.object] for path in pathlist]
448 for tv_node in nodes:
449 yield tv_node
451 def foreach_selected(self, func):
452 selection = self._widget.get_selection()
453 (model, pathlist) = selection.get_selected_rows()
454 iters = [model.get_iter(path) for path in pathlist]
455 try:
456 for treeiter in iters:
457 object = model.get_value(treeiter, Column.object)
458 func(object, model, treeiter)
459 except TypeError, te:
460 logging.exception(te)
462 def on_menu_add_child_activate(self, *args):
463 self.begin_add_category(self.node_at_popup.node)
465 def begin_add_category(self, node):
466 category = Category()
467 category.parent = node
468 category.norder = len(node.children)
469 self.new_child = TreeViewNode(category, self.model.store)
470 iter = self.model._create_row(self.new_child, editable = True)
471 path = self.model.store.get_path(iter)
472 column = self._widget.get_column(0)
474 parent_path = self.new_child.parent_path
476 if parent_path:
477 self._widget.expand_row(parent_path, False)
479 self._widget.set_cursor_on_cell(path, focus_column = column, start_editing = True)
481 def on_menu_poll_selected_activate(self, *args):
482 """config = Config.get_instance()
484 if config.offline: #XXX
485 config.offline = not config.offline"""
487 FeedManager.update_nodes([node.node for node in self.selected()])
489 def on_menu_stop_poll_selected_activate(self, *args):
490 self.foreach_selected(lambda o,*args: o.feed.router.stop_polling())
492 def on_menu_mark_all_as_read_activate(self, *args):
493 self.foreach_selected(lambda o,*args: o.feed.mark_items_as_read())
495 def on_remove_selected_feed(self, *args):
496 nodes = [tv_node for tv_node in self.selected()]
498 FeedManager.delete_nodes([tv_node.node.id for tv_node in nodes])
500 for node in nodes:
501 iter = node.iter
503 if iter:
504 self.model.store.remove(iter)
506 def on_node_edit_title_canceled(self, cellrenderer):
507 if self.new_child:
508 debug("on_node_edit_title_canceled (new_child), removing %s" % str(self.new_child.path))
509 self.model.store.remove(self.new_child.iter)
510 self.new_child = None
512 def on_node_edit_title_edited(self, cellrenderertext, path, new_text):
513 if len(new_text) > 0:
514 self.new_child.node.name = new_text
515 FeedManager.save_category(self.new_child.node)
517 self.model.store.remove(self.new_child.iter)
518 self.new_child = None
520 def on_display_properties_feed(self, *args):
521 selected_tv_node = [tv_node for tv_node in self.selected()][0]
522 self._presenter.show_feed_information(selected_tv_node.node)
524 def add_category(self):
525 self.begin_add_category(self._model.tv_nodes[1].node)
527 def select_first_feed(self):
528 selection = self._widget.get_selection()
529 (model, pathlist) = selection.get_selected_rows()
530 treeiter = model.get_iter_first()
531 if not treeiter or not model.iter_is_valid(treeiter):
532 return False
533 self.set_cursor(treeiter)
534 return True
536 def select_next_feed(self, with_unread=False):
537 ''' Scrolls to the next feed in the feed list
539 If there is no selection, selects the first feed. If multiple feeds
540 are selected, selects the feed after the last selected feed.
542 If unread is True, selects the next unread with unread items.
544 If the selection next-to-be is a category, go to the iter its first
545 child. If current selection is a child, then go to (parent + 1),
546 provided that (parent + 1) is not a category.
548 has_unread = False
549 def next(model, current):
550 treeiter = model.iter_next(current)
551 if not treeiter: return False
552 if model.iter_depth(current): next(model, model.iter_parent(current))
553 path = model.get_path(treeiter)
554 if with_unread and model[path][Column.unread] < 1:
555 next(model, current)
556 self.set_cursor(treeiter)
557 return True
558 selection = self._widget.get_selection()
559 (model, pathlist) = selection.get_selected_rows()
560 iters = [model.get_iter(path) for path in pathlist]
561 try:
562 current = iters.pop()
563 if model.iter_has_child(current):
564 iterchild = model.iter_children(current)
565 # make the row visible
566 path = model.get_path(iterchild)
567 for i in range(len(path)):
568 self._widget.expand_row(path[:i+1], False)
569 # select his first born child
570 if with_unread and model[path][Column.unread] > 0:
571 self.set_cursor(iterchild)
572 has_unread = True
573 else:
574 has_unread = next(model, current)
575 has_unread = next(model,current)
576 except IndexError:
577 self.set_cursor(model.get_iter_first())
578 has_unread = True
579 return has_unread
581 def select_previous_feed(self):
582 ''' Scrolls to the previous feed in the feed list.
584 If there is no selection, selects the first feed. If there's multiple
585 selection, selects the feed before the first selected feed.
587 If the previous selection is a category, select the last node in that
588 category. If the current selection is a child, then go to (parent -
589 1). If parent is the first feed, wrap and select the last feed or
590 category in the list.
592 def previous(model, current):
593 path = model.get_path(current)
594 treerow = model[path[-1]-1]
595 self.set_cursor(treerow.iter)
596 selection = self._widget.get_selection()
597 (model, pathlist) = selection.get_selected_rows()
598 iters = [model.get_iter(path) for path in pathlist]
599 try:
600 current_first = iters.pop(0)
601 if model.iter_has_child(current_first):
602 children = model.iter_n_children(current_first)
603 treeiter = model.iter_nth_child(children - 1)
604 self.set_cursor(treeiter)
605 return
606 previous(model, current_first)
607 except IndexError:
608 self.set_cursor(model.get_iter_first())
609 return
611 def set_cursor(self, treeiter, col_id=None, edit=False):
612 if not treeiter:
613 return
615 column = None
616 path = self._model.model.get_path(treeiter)
618 if col_id:
619 column = self._widget.get_column(col_id)
621 self._widget.set_cursor(path, column, edit)
622 self._widget.scroll_to_cell(path, column)
623 self._widget.grab_focus()
625 class FeedsPresenter(MVP.BasicPresenter):
626 def _initialize(self):
627 self.model = FeedListModel()
628 self._init_signals()
630 def _init_signals(self):
631 pass
633 def add_category(self):
634 self.view.add_category()
636 def select_first_feed(self):
637 return self.view.select_first_feed()
639 def select_next_feed(self, with_unread=False):
640 return self.view.select_next_feed(with_unread)
642 def select_previous_feed(self):
643 return self.view.select_previous_feed()
645 def _sort_func(self, model, a, b):
647 Sorts the feeds lexically.
649 From the gtk.TreeSortable.set_sort_func doc:
651 The comparison callback should return -1 if the iter1 row should come before
652 the iter2 row, 0 if the rows are equal, or 1 if the iter1 row should come
653 after the iter2 row.
655 retval = 0
656 fa = model.get_value(a, Column.OBJECT)
657 fb = model.get_value(b, Column.OBJECT)
659 if fa and fb:
660 retval = locale.strcoll(fa.title, fb.title)
661 elif fa is not None: retval = -1
662 elif fb is not None: retval = 1
663 return retval
665 def show_feed_information(self, node):
666 if node.type == "C":
667 properties = categoryproperties
668 elif node.type == "F":
669 properties = feedproperties
671 properties.show(node)