work on aggregating items of category feeds and minor fixes
[straw.git] / src / lib / FeedListView.py
blob5e2a98582158d483bcd464f7a35df8cf6204c136
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 import copy
21 import locale
22 from logging import debug, error, warn, info, critical, exception
23 import os
24 import pygtk
25 pygtk.require('2.0')
26 import gobject
27 import gtk
28 import pango
29 import FeedCategoryList
30 import FeedPropertiesDialog
31 import feeds
32 import Event
33 import dialogs
34 import Config
35 import PollManager
36 import MVP
38 import utils
40 class Column:
41 pixbuf, name, foreground, object = range(4)
44 class TreeNodeAdapter (gobject.GObject):
45 ''' A Node Adapter which encapsulates either a Category or a Feed '''
47 __gsignals__ = {
48 'feed-changed' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE,
49 (gobject.TYPE_PYOBJECT,)),
50 'category-changed' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE,
51 (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT,)),
52 'category-feed-added' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE,
53 (gobject.TYPE_PYOBJECT,)),
54 'category-feed-removed': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE,
55 (gobject.TYPE_PYOBJECT,))
58 def __init__(self, object):
59 gobject.GObject.__init__(self)
60 self.obj = object
61 filename = os.path.join(utils.find_image_dir(), 'feed.png')
62 self.default_pixbuf = gtk.gdk.pixbuf_new_from_file(filename)
63 if isinstance(self.obj, feeds.Feed):
64 self.obj.connect('changed', self.feed_changed_cb)
65 elif isinstance(self.obj, FeedCategoryList.FeedCategory):
66 self.obj.connect('feed-added', self.feed_added_cb)
67 self.obj.connect('feed-removed', self.feed_removed_cb)
68 self.obj.connect('changed', self.category_changed_cb)
70 def feed_changed_cb(self, feed):
71 self.emit('feed-changed', feed)
73 def category_changed_cb(self, category, *args):
74 print "TREENODEADAPTER CATEGORY_CHANGED_CB -> ", category, feed
75 self.emit('category-changed', category, args)
77 def feed_added_cb(self, category, feed):
78 self.emit('category-feed-added', feed)
80 def feed_removed_cb(self, category, feed):
81 self.emit('category-feed-removed', feed)
83 def has_children(self):
84 ''' Checks if the node has children. Essentially this means the object
85 is a category.'''
86 has_child = False
87 try:
88 has_child = self.obj.feeds and True or False
89 except AttributeError:
90 has_child = False
91 return has_child
93 @property
94 def title(self):
95 ''' The title of the node be it a category or a feed '''
96 return self.obj.title
98 @property
99 def num_unread_items(self):
100 ''' The number of unread items of the feed or if it's a category,
101 the aggregate number of unread items of the feeds belonging to the
102 category.'''
103 unread_items = 0
104 try:
105 unread_items = self.obj.number_of_unread
106 except AttributeError:
107 unread_items = reduce(lambda a,b: a.number_of_unread + b.number_of_unread,
108 self.obj.feeds)
109 print "number of unread of category is", unread_items
110 return unread_items
112 @property
113 def pixbuf(self):
114 ''' gets the pixbuf to display according to the status of the feed '''
115 widget = gtk.Label()
116 # ignore why above is a gtk.Label. We just need
117 # gtk.Widget.render_icon to get a pixbuf out of a stock image.
118 # Fyi, gtk.Widget is abstract that is why we need a subclass to do
120 # this for us.
121 try:
122 if self.obj.process_status is not feeds.Feed.STATUS_IDLE:
123 return widget.render_icon(gtk.STOCK_EXECUTE, gtk.ICON_SIZE_MENU)
124 elif self.obj.error:
125 return widget.render_icon(gtk.STOCK_DIALOG_ERROR, gtk.ICON_SIZE_MENU)
126 except AttributeError, ex:
127 print ex
128 return widget.render_icon(gtk.STOCK_DIRECTORY, gtk.ICON_SIZE_MENU)
129 return self.default_pixbuf
131 @property
132 def feed(self):
133 ''' An alias to a Feed object '''
134 if not isinstance(self.obj, feeds.Feed):
135 raise TypeError, _("object is not of a Feed")
136 return self.obj
138 @property
139 def category(self):
140 ''' An alias to a Category object '''
141 if not isinstance(self.obj, FeedCategoryList.FeedCategory):
142 raise TypeError, _("object is not a Category")
143 return self.obj
146 class FeedListPopup:
147 ''' Abstracts a popup widget '''
149 def __init__(self, listener):
150 self.manager = gtk.UIManager()
151 actions = [
152 ("refresh", gtk.STOCK_REFRESH, _("_Refresh"), None, _("Update this feed"),
153 listener.on_menu_poll_selected_activate),
154 ("mark_as_read", gtk.STOCK_CLEAR, _("_Mark As Read"), None, _("Mark all items in this feed as read"),
155 listener.on_menu_mark_all_as_read_activate),
156 ("stop_refresh", None, _("_Stop Refresh"), None, _("Stop updating this feed"),
157 listener.on_menu_stop_poll_selected_activate),
158 ("remove", None, _("Remo_ve Feed"), None, _("Remove this feed from my subscription"),
159 listener.on_remove_selected_feed),
160 ("category-sort",None,_("_Arrange Feeds"), None, _("Sort the current category")),
161 ("ascending", gtk.STOCK_SORT_ASCENDING, _("Alpha_betical Order"), None, _("Sort in alphabetical order"),
162 listener.on_sort_ascending),
163 ("descending", gtk.STOCK_SORT_DESCENDING, _("Re_verse Order"), None, _("Sort in reverse order"),
164 listener.on_sort_descending),
165 ("properties", gtk.STOCK_INFO, _("_Information"), None, _("Feed-specific properties"),
166 listener.on_display_properties_feed)
168 ag = gtk.ActionGroup('FeedListPopupActions')
169 ag.add_actions(actions)
170 self.manager.insert_action_group(ag,0)
171 popupui = os.path.join(utils.find_image_dir(), 'ui.xml')
172 self.manager.add_ui_from_file(popupui)
174 @property
175 def popup(self):
176 return self.manager.get_widget('/feed_list_popup')
179 class FeedListModel:
180 ''' The model for the feed list view '''
182 def __init__(self):
183 # name, pixbuf, unread, foreground
184 self.store = gtk.TreeStore(gtk.gdk.Pixbuf, str, str, gobject.TYPE_PYOBJECT)
185 # unread, weight, status_flag feed object, allow_children
186 # str, int, 'gboolean', gobject.TYPE_PYOBJECT, 'gboolean'
187 self.fclist = FeedCategoryList.get_instance()
189 # build the user categories and its feeds
190 populate = self.populate
191 for category in self.fclist.user_categories:
192 populate(category, category.feeds)
193 populate(None, self.fclist.un_category.feeds)
195 def populate(self, category, feeds):
196 parent = None
197 if category:
198 parent = self.store.append(parent)
199 node_adapter = TreeNodeAdapter(category)
200 node_adapter.connect('category-changed', self.category_changed_cb)
201 node_adapter.connect('category-feed-added', self.feed_added_cb)
202 node_adapter.connect('category-feed-removed', self.feed_removed_cb)
203 self.store.set(parent, Column.pixbuf, node_adapter.pixbuf,
204 Column.name, node_adapter.title,
205 Column.object, node_adapter)
206 for f in feeds:
207 rowiter = self.store.append(parent)
208 node_adapter = TreeNodeAdapter(f)
209 node_adapter.connect('feed-changed', self.feed_changed_cb)
210 self.store.set(rowiter, Column.pixbuf, node_adapter.pixbuf,
211 Column.name, node_adapter.title,
212 Column.foreground, self.get_foreground(node_adapter.num_unread_items),
213 Column.object, node_adapter) # maybe use create_adapter(f) here?
215 def __getattribute__(self, name):
216 attr = None
217 try:
218 attr = getattr(self, name)
219 except AttributeError, ae:
220 attr = getattr(self.store, name)
221 else:
222 return attr
224 def category_changed_cb(self, node_adapter, *args):
225 # XXX What changes do we need here? new feed? udated subscription?
226 print "FEED LIST MODEL -> ", node_adapter, args
228 def feed_added_cb(self, node_adapter, feed):
229 if node_adapter.category is self.fclist.all_category: #XXX should really fix this
230 return
231 treemodelrow = self.search(self.store,
232 lambda r, d: r[d[0]] == d[1],
233 (Column.object, node_adapter))
234 feed_node_adapter = TreeNodeAdapter(feed)
235 feed_node_adapter.connect('feed-changed', self.feed_changed_cb)
236 self.store.append(treemodelrow.iter, [feed_node_adapter.pixbuf, feed_node_adapter.title,
237 self.get_foreground(feed_node_adapter.num_unread_items),
238 feed_node_adapter])
240 def feed_removed_cb(self, node_adapter, feed):
241 print "FEED REMOVED CB ", node_adapter, feed
243 def feed_changed_cb(self, node_adapter, feed):
244 print "FEED CHANGED ", node_adapter, feed
245 row = self.search(self.store,
246 lambda r, data: r[data[0]] == data[1],
247 (Column.object, node_adapter))
248 if not row: return
249 path = self.store.get_path(row.iter)
250 self.store[path] = [node_adapter.pixbuf, node_adapter.title,
251 self.get_foreground(node_adapter.num_unread_items),
252 node_adapter]
254 def get_foreground(self, unread):
255 ''' gets the foreground color according to the number of unread items'''
256 return ('black', 'blue')[(unread > 0) and 1 or 0]
258 @property
259 def model(self):
260 return self.store
262 def search(self, rows, func, data):
263 if not rows: return None
264 for row in rows:
265 if func(row, data):
266 return row
267 result = self.search(row.iterchildren(), func, data)
268 if result: return result
269 return None
271 class FeedsView(MVP.WidgetView):
272 def _initialize(self):
273 self._widget.set_search_column(Column.name)
275 # pixbuf column
276 column = gtk.TreeViewColumn()
277 status_renderer = gtk.CellRendererPixbuf()
278 column.pack_start(status_renderer, False)
279 column.set_attributes(status_renderer,
280 pixbuf=Column.pixbuf)
282 # feed title renderer
283 title_renderer = gtk.CellRendererText()
284 column.pack_start(title_renderer, False)
285 column.set_attributes(title_renderer,
286 foreground=Column.foreground,
287 text=Column.name) #, weight=Column.BOLD)
289 self._widget.append_column(column)
291 selection = self._widget.get_selection()
292 selection.set_mode(gtk.SELECTION_MULTIPLE)
294 self._widget.connect("button_press_event", self._on_button_press_event)
295 self._widget.connect("popup-menu", self._on_popup_menu)
297 self._popup = FeedListPopup(self).popup
299 def _model_set(self):
300 self._widget.set_model(self._model.model)
302 def add_selection_changed_listener(self, listener):
303 selection = self._widget.get_selection()
304 selection.connect('changed', listener.feedlist_selection_changed, Column.object)
306 def _on_popup_menu(self, treeview, *args):
307 self._popup.popup(None, None, None, 0, 0)
309 def foreach_selected(self, func):
310 selection = self._widget.get_selection()
311 (model, pathlist) = selection.get_selected_rows()
312 iters = [model.get_iter(path) for path in pathlist]
313 try:
314 for treeiter in iters:
315 object = model.get_value(treeiter, Column.object)
316 func(object, model, treeiter)
317 except TypeError, te:
318 ## XXX maybe object is a category
319 print te
320 return
322 def _on_button_press_event(self, treeview, event):
323 retval = 0
324 if event.button == 3:
325 x = int(event.x)
326 y = int(event.y)
327 time = gtk.get_current_event_time()
328 path = treeview.get_path_at_pos(x, y)
329 if path is None:
330 return 1
331 path, col, cellx, celly = path
332 treeview.grab_focus()
333 self._popup.popup(None, None, None, event.button, time)
334 retval = 1
335 return retval
337 ### XXX don't do feed related tasks when path is a category this applies to every operation I suppose
338 def on_sort_ascending(self, *args):
339 ## XXX this will not work with tree list
340 self._presenter.sort_category()
342 def on_sort_descending(self, *args):
343 ### XXX this will not work with tree list
344 self._presenter.sort_category(reverse=True)
346 def on_menu_poll_selected_activate(self, *args):
347 config = Config.get_instance()
348 poll = True
349 if config.offline: #XXX
350 config.offline = not config.offline
351 selection = self._widget.get_selection()
352 (model, pathlist) = selection.get_selected_rows()
353 iters = [model.get_iter(path) for path in pathlist]
354 feeds = [o.feed for o in [model.get_value(treeiter,Column.object) for treeiter in iters]]
355 PollManager.get_instance().poll(feeds)
356 return
358 def on_menu_stop_poll_selected_activate(self, *args):
359 self.foreach_selected(lambda o,*args: o.feed.router.stop_polling())
361 def on_menu_mark_all_as_read_activate(self, *args):
362 self.foreach_selected(lambda o,*args: o.feed.mark_all_items_as_read())
364 def on_remove_selected_feed(self, *args):
365 def remove(*args):
366 (object, model, treeiter) = args
367 model.remove(treeiter)
368 feedlist = feeds.get_instance()
369 idx = feedlist.index(object.feed)
370 del feedlist[idx]
371 self.foreach_selected(remove)
372 return
374 def on_display_properties_feed(self, *args):
375 selection = self._widget.get_selection()
376 (model, pathlist) = selection.get_selected_rows()
377 iters = [model.get_iter(path) for path in pathlist]
378 path = pathlist.pop()
379 node_adapter = self.model.model[path][Column.object]
380 self._presenter.show_feed_information(node_adapter.feed)
381 return
383 def select_first_feed(self):
384 treeiter = self._model.get_iter_first()
385 if not treeiter or not self._model.iter_is_valid(treeiter):
386 return False
387 self._view.set_cursor(treeiter)
388 return True
390 def select_next_feed(self, unread=False):
391 ''' Scrolls to the next feed in the feed list
393 If there is no selection, selects the first feed. If multiple feeds
394 are selected, selects the feed after the last selected feed.
396 If unread is True, selects the next unread with unread items.
398 If the selection next-to-be is a category, go to the iter its first
399 child. If current selection is a child, then go to (parent + 1),
400 provided that (parent + 1) is not a category.
402 def next(model, current):
403 treeiter = model.iter_next(current)
404 if not treeiter and model.iter_depth(current):
405 next(model, model.iter_parent(current))
406 self.set_cursor(treeiter)
407 selection = self._widget.get_selection()
408 (model, pathlist) = selection.get_selected_rows()
409 iters = [model.get_iter(path) for path in pathlist]
410 try:
411 current = iters.pop()
412 if model.iter_has_child(current):
413 iterchild = model.iter_children(current)
414 # make the row visible
415 path = model.get_path(iterchild)
416 for i in range(len(path)):
417 self._widget.expand_row(path[:i+1], False)
418 self.set_cursor(iterchild)
419 return
420 next(model,current)
421 except IndexError:
422 self.set_cursor(model.get_iter_first())
424 def select_previous_feed(self):
425 ''' Scrolls to the previous feed in the feed list.
427 If there is no selection, selects the first feed. If there's multiple
428 selection, selects the feed before the first selected feed.
430 If the previous selection is a category, select the last node in that
431 category. If the current selection is a child, then go to (parent -
432 1). If parent is the first feed, wrap and select the last feed or
433 category in the list.
435 def previous(model, current):
436 path = model.get_path(current)
437 path_len = len(path)
438 #path_prev = path[:path_len
439 print "\tpath is -> %s , prev_path -> %s", (path, prev_path)
440 treeiter = model.get_iter(prev_path)
441 self.set_cursor(treeiter)
442 selection = self._widget.get_selection()
443 (model, pathlist) = selection.get_selected_rows()
444 iters = [model.get_iter(path) for path in pathlist]
445 try:
446 current = iters.pop(0)
447 if model.iter_has_child(current):
448 kids = model.iter_n_children(current)
449 iter = model.iter_nth_child(kids - 1)
450 self.set_cursor(iter)
451 return
452 previous(model, current)
453 except IndexError:
454 self.set_cursor(model.get_iter_first())
456 def select_next_unread_feed(self):
457 has_unread = False
458 mark_treerow = 1
459 treerow = self._model[0]
460 selection = self._view.get_selection()
461 srow = selection.get_selected()
462 if srow:
463 model, treeiter = srow
464 nextiter = model.iter_next(treeiter)
465 if nextiter:
466 treerow = self._model[model.get_path(nextiter)]
467 while(treerow):
468 feedrow = treerow[Column.OBJECT]
469 if feedrow.feed.number_of_unread:
470 self._view.set_cursor(treerow.iter)
471 has_unread = True
472 break
473 treerow = treerow.next
474 if not treerow and mark_treerow:
475 # should only do this once.
476 mark_treerow = treerow
477 treerow = self._model[0]
478 return has_unread
481 def set_cursor(self, treeiter, col_id=None, edit=False):
482 if not treeiter:
483 return
484 column = None
485 path = self._model.model.get_path(treeiter)
486 if col_id:
487 column = self._widget.get_column(col_id)
488 self._widget.set_cursor(path, column, edit)
489 self._widget.scroll_to_cell(path, column)
490 self._widget.grab_focus()
491 return
493 class FeedsPresenter(MVP.BasicPresenter):
494 def _initialize(self):
495 self.model = FeedListModel()
496 # self._init_signals()
497 return
499 def _init_signals(self):
500 flist = feeds.get_instance()
501 #flist.signal_connect(Event.ItemReadSignal,
502 # self._feed_item_read)
503 #flist.signal_connect(Event.AllItemsReadSignal,
504 # self._feed_all_items_read)
505 #flist.signal_connect(Event.FeedsChangedSignal,
506 # self._feeds_changed)
507 #flist.signal_connect(Event.FeedDetailChangedSignal,
508 # self._feed_detail_changed)
509 #fclist = FeedCategoryList.get_instance()
510 #fclist.signal_connect(Event.FeedCategorySortedSignal,
511 # self._feeds_sorted_cb)
512 #fclist.signal_connect(Event.FeedCategoryChangedSignal,
513 # self._fcategory_changed_cb)
515 def select_next_feed(self, unread=False):
516 self.view.select_next_feed(unread)
518 def select_previous_feed(self):
519 self.view.select_previous_feed()
521 def _sort_func(self, model, a, b):
523 Sorts the feeds lexically.
525 From the gtk.TreeSortable.set_sort_func doc:
527 The comparison callback should return -1 if the iter1 row should come before
528 the iter2 row, 0 if the rows are equal, or 1 if the iter1 row should come
529 after the iter2 row.
531 retval = 0
532 fa = model.get_value(a, Column.OBJECT)
533 fb = model.get_value(b, Column.OBJECT)
535 if fa and fb:
536 retval = locale.strcoll(fa.title, fb.title)
537 elif fa is not None: retval = -1
538 elif fb is not None: retval = 1
539 return retval
541 #def sort_category(self, reverse=False):
542 # self._curr_category.sort()
543 # if reverse:
544 # self._curr_category.reverse()
545 # return
547 def show_feed_information(self, feed):
548 FeedPropertiesDialog.show_feed_properties(None, feed)
549 return