more Event cleanups and ItemList module refactoring
[straw.git] / src / lib / FeedListView.py
blob013cbcf88c634fa5201df5d353a6e07b1a48534b
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 for category in self.fclist.all_categories:
191 # .. except for all_category
192 if category is self.fclist.all_category:
193 continue
195 parent = None
196 parent = self.store.append(parent)
197 node_adapter = TreeNodeAdapter(category)
198 node_adapter.connect('category-changed', self.category_changed_cb)
199 node_adapter.connect('category-feed-added', self.feed_added_cb)
200 node_adapter.connect('category-feed-removed', self.feed_removed_cb)
201 self.store.set(parent, Column.pixbuf, node_adapter.pixbuf,
202 Column.name, node_adapter.title,
203 Column.object, node_adapter)
204 for f in category.feeds:
205 rowiter = self.store.append(parent)
206 node_adapter = TreeNodeAdapter(f)
207 node_adapter.connect('feed-changed', self.feed_changed_cb)
208 self.store.set(rowiter, Column.pixbuf, node_adapter.pixbuf,
209 Column.name, node_adapter.title,
210 Column.foreground, self.get_foreground(node_adapter.num_unread_items),
211 Column.object, node_adapter) # maybe use create_adapter(f) here?
213 def __getattribute__(self, name):
214 attr = None
215 try:
216 attr = getattr(self, name)
217 except AttributeError, ae:
218 attr = getattr(self.store, name)
219 else:
220 return attr
222 def category_changed_cb(self, node_adapter, *args):
223 # XXX What changes do we need here? new feed? udated subscription?
224 print "FEED LIST MODEL -> ", node_adapter, args
226 def feed_added_cb(self, node_adapter, feed):
227 if node_adapter.category is self.fclist.all_category: #XXX should really fix this
228 return
229 treemodelrow = self.search(self.store,
230 lambda r, d: r[d[0]] == d[1],
231 (Column.object, node_adapter))
232 feed_node_adapter = TreeNodeAdapter(feed)
233 feed_node_adapter.connect('feed-changed', self.feed_changed_cb)
234 self.store.append(treemodelrow.iter, [feed_node_adapter.pixbuf, feed_node_adapter.title,
235 self.get_foreground(feed_node_adapter.num_unread_items),
236 feed_node_adapter])
238 def feed_removed_cb(self, node_adapter, feed):
239 print "FEED REMOVED CB ", node_adapter, feed
241 def feed_changed_cb(self, node_adapter, feed):
242 print "FEED CHANGED ", node_adapter, feed
243 row = self.search(self.store,
244 lambda r, data: r[data[0]] == data[1],
245 (Column.object, node_adapter))
246 if not row: return
247 path = self.store.get_path(row.iter)
248 self.store[path] = [node_adapter.pixbuf, node_adapter.title,
249 self.get_foreground(node_adapter.num_unread_items),
250 node_adapter]
252 def get_foreground(self, unread):
253 ''' gets the foreground color according to the number of unread items'''
254 return ('black', 'blue')[(unread > 0) and 1 or 0]
256 @property
257 def model(self):
258 return self.store
260 ## XXX DEPRECATE
261 def get_selected_nodes(self, pathlist):
262 ''' Returns a list of feed objects that live in the pathlist '''
263 selected = []
264 for path in pathlist:
265 treeiter = self.store.get_iter(path)
266 object = self.store.get_value(treeiter, Column.object)
267 selected.append(object)
268 return selected
270 def search(self, rows, func, data):
271 if not rows: return None
272 for row in rows:
273 if func(row, data):
274 return row
275 result = self.search(row.iterchildren(), func, data)
276 if result: return result
277 return None
279 class FeedsView(MVP.WidgetView):
280 def _initialize(self):
281 self._widget.set_search_column(Column.name)
283 # pixbuf column
284 column = gtk.TreeViewColumn()
285 status_renderer = gtk.CellRendererPixbuf()
286 column.pack_start(status_renderer, False)
287 column.set_attributes(status_renderer,
288 pixbuf=Column.pixbuf)
290 # feed title renderer
291 title_renderer = gtk.CellRendererText()
292 column.pack_start(title_renderer, False)
293 column.set_attributes(title_renderer,
294 foreground=Column.foreground,
295 text=Column.name) #, weight=Column.BOLD)
297 self._widget.append_column(column)
299 selection = self._widget.get_selection()
300 selection.set_mode(gtk.SELECTION_MULTIPLE)
302 self._widget.connect("button_press_event", self._on_button_press_event)
303 self._widget.connect("popup-menu", self._on_popup_menu)
305 self._popup = FeedListPopup(self).popup
307 def _model_set(self):
308 self._widget.set_model(self._model.model)
310 def add_selection_changed_listener(self, listener):
311 selection = self._widget.get_selection()
312 selection.connect('changed', listener.feedlist_selection_changed, Column.object)
314 def _on_popup_menu(self, treeview, *args):
315 self._popup.popup(None, None, None, 0, 0)
317 def foreach_selected(self, func):
318 selection = self._widget.get_selection()
319 (model, pathlist) = selection.get_selected_rows()
320 iters = [model.get_iter(path) for path in pathlist]
321 try:
322 for treeiter in iters:
323 object = model.get_value(treeiter, Column.object)
324 func(object, model, treeiter)
325 except TypeError, te:
326 ## XXX maybe object is a category
327 print te
328 return
330 def _on_button_press_event(self, treeview, event):
331 retval = 0
332 if event.button == 3:
333 x = int(event.x)
334 y = int(event.y)
335 time = gtk.get_current_event_time()
336 path = treeview.get_path_at_pos(x, y)
337 if path is None:
338 return 1
339 path, col, cellx, celly = path
340 treeview.grab_focus()
341 self._popup.popup(None, None, None, event.button, time)
342 retval = 1
343 return retval
345 ### XXX don't do feed related tasks when path is a category this applies to every operation I suppose
346 def on_sort_ascending(self, *args):
347 ## XXX this will not work with tree list
348 self._presenter.sort_category()
350 def on_sort_descending(self, *args):
351 ### XXX this will not work with tree list
352 self._presenter.sort_category(reverse=True)
354 def on_menu_poll_selected_activate(self, *args):
355 config = Config.get_instance()
356 poll = True
357 if config.offline: #XXX
358 config.offline = not config.offline
359 selection = self._widget.get_selection()
360 (model, pathlist) = selection.get_selected_rows()
361 iters = [model.get_iter(path) for path in pathlist]
362 feeds = [o.feed for o in [model.get_value(treeiter,Column.object) for treeiter in iters]]
363 PollManager.get_instance().poll(feeds)
364 return
366 def on_menu_stop_poll_selected_activate(self, *args):
367 self.foreach_selected(lambda o,*args: o.feed.router.stop_polling())
369 def on_menu_mark_all_as_read_activate(self, *args):
370 self.foreach_selected(lambda o,*args: o.feed.mark_all_items_as_read())
372 def on_remove_selected_feed(self, *args):
373 def remove(*args):
374 (object, model, treeiter) = args
375 model.remove(treeiter)
376 feedlist = feeds.get_instance()
377 idx = feedlist.index(object.feed)
378 del feedlist[idx]
379 self.foreach_selected(remove)
380 return
382 def on_display_properties_feed(self, *args):
383 selection = self._widget.get_selection()
384 (model, pathlist) = selection.get_selected_rows()
385 iters = [model.get_iter(path) for path in pathlist]
386 path = pathlist.pop()
387 node_adapter = self.model.model[path][Column.object]
388 self._presenter.show_feed_information(node_adapter.feed)
389 return
391 def select_first_feed(self):
392 treeiter = self._model.get_iter_first()
393 if not treeiter or not self._model.iter_is_valid(treeiter):
394 return False
395 self._view.set_cursor(treeiter)
396 return True
398 def select_next_feed(self, unread=False):
399 ''' Scrolls to the next feed in the feed list
401 If there is no selection, selects the first feed. If multiple feeds
402 are selected, selects the feed after the last selected feed.
404 If unread is True, selects the next unread with unread items.
406 If the selection next-to-be is a category, go to the iter its first
407 child. If current selection is a child, then go to (parent + 1),
408 provided that (parent + 1) is not a category.
410 def next(model, current):
411 treeiter = model.iter_next(current)
412 if not treeiter and model.iter_depth(current):
413 next(model, model.iter_parent(current))
414 self.set_cursor(treeiter)
415 selection = self._widget.get_selection()
416 (model, pathlist) = selection.get_selected_rows()
417 iters = [model.get_iter(path) for path in pathlist]
418 try:
419 current = iters.pop()
420 if model.iter_has_child(current):
421 iterchild = model.iter_children(current)
422 # make the row visible
423 path = model.get_path(iterchild)
424 for i in range(len(path)):
425 self._widget.expand_row(path[:i+1], False)
426 self.set_cursor(iterchild)
427 return
428 next(model,current)
429 except IndexError:
430 self.set_cursor(model.get_iter_first())
432 def select_previous_feed(self):
433 ''' Scrolls to the previous feed in the feed list.
435 If there is no selection, selects the first feed. If there's multiple
436 selection, selects the feed before the first selected feed.
438 If the previous selection is a category, select the last node in that
439 category. If the current selection is a child, then go to (parent -
440 1). If parent is the first feed, wrap and select the last feed or
441 category in the list.
443 def previous(model, current):
444 path = model.get_path(current)
445 path_len = len(path)
446 #path_prev = path[:path_len
447 print "\tpath is -> %s , prev_path -> %s", (path, prev_path)
448 treeiter = model.get_iter(prev_path)
449 self.set_cursor(treeiter)
450 selection = self._widget.get_selection()
451 (model, pathlist) = selection.get_selected_rows()
452 iters = [model.get_iter(path) for path in pathlist]
453 try:
454 current = iters.pop(0)
455 if model.iter_has_child(current):
456 kids = model.iter_n_children(current)
457 iter = model.iter_nth_child(kids - 1)
458 self.set_cursor(iter)
459 return
460 previous(model, current)
461 except IndexError:
462 self.set_cursor(model.get_iter_first())
464 def select_next_unread_feed(self):
465 has_unread = False
466 mark_treerow = 1
467 treerow = self._model[0]
468 selection = self._view.get_selection()
469 srow = selection.get_selected()
470 if srow:
471 model, treeiter = srow
472 nextiter = model.iter_next(treeiter)
473 if nextiter:
474 treerow = self._model[model.get_path(nextiter)]
475 while(treerow):
476 feedrow = treerow[Column.OBJECT]
477 if feedrow.feed.number_of_unread:
478 self._view.set_cursor(treerow.iter)
479 has_unread = True
480 break
481 treerow = treerow.next
482 if not treerow and mark_treerow:
483 # should only do this once.
484 mark_treerow = treerow
485 treerow = self._model[0]
486 return has_unread
489 def set_cursor(self, treeiter, col_id=None, edit=False):
490 if not treeiter:
491 return
492 column = None
493 path = self._model.model.get_path(treeiter)
494 if col_id:
495 column = self._widget.get_column(col_id)
496 self._widget.set_cursor(path, column, edit)
497 self._widget.scroll_to_cell(path, column)
498 self._widget.grab_focus()
499 return
501 class FeedsPresenter(MVP.BasicPresenter):
502 def _initialize(self):
503 self.model = FeedListModel()
504 # self._init_signals()
505 return
507 def _init_signals(self):
508 flist = feeds.get_instance()
509 #flist.signal_connect(Event.ItemReadSignal,
510 # self._feed_item_read)
511 #flist.signal_connect(Event.AllItemsReadSignal,
512 # self._feed_all_items_read)
513 #flist.signal_connect(Event.FeedsChangedSignal,
514 # self._feeds_changed)
515 #flist.signal_connect(Event.FeedDetailChangedSignal,
516 # self._feed_detail_changed)
517 #fclist = FeedCategoryList.get_instance()
518 #fclist.signal_connect(Event.FeedCategorySortedSignal,
519 # self._feeds_sorted_cb)
520 #fclist.signal_connect(Event.FeedCategoryChangedSignal,
521 # self._fcategory_changed_cb)
523 def select_next_feed(self, unread=False):
524 self.view.select_next_feed(unread)
526 def select_previous_feed(self):
527 self.view.select_previous_feed()
529 def _sort_func(self, model, a, b):
531 Sorts the feeds lexically.
533 From the gtk.TreeSortable.set_sort_func doc:
535 The comparison callback should return -1 if the iter1 row should come before
536 the iter2 row, 0 if the rows are equal, or 1 if the iter1 row should come
537 after the iter2 row.
539 retval = 0
540 fa = model.get_value(a, Column.OBJECT)
541 fb = model.get_value(b, Column.OBJECT)
543 if fa and fb:
544 retval = locale.strcoll(fa.title, fb.title)
545 elif fa is not None: retval = -1
546 elif fb is not None: retval = 1
547 return retval
549 #def sort_category(self, reverse=False):
550 # self._curr_category.sort()
551 # if reverse:
552 # self._curr_category.reverse()
553 # return
555 def show_feed_information(self, feed):
556 FeedPropertiesDialog.show_feed_properties(None, feed)
557 return