iteration 1 - Use gobject for events in feed, summaryitem and feedlist
[straw.git] / src / lib / FeedListView.py
blobea4c6b0d1289e6fbb45cd60afc30ecbd5e6f9a38
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 FeedListPopup:
45 ''' Abstracts a popup widget '''
47 def __init__(self, listener):
48 self.manager = gtk.UIManager()
49 actions = [
50 ("refresh", gtk.STOCK_REFRESH, _("_Refresh"), None, _("Update this feed"),
51 listener.on_menu_poll_selected_activate),
52 ("mark_as_read", gtk.STOCK_CLEAR, _("_Mark As Read"), None, _("Mark all items in this feed as read"),
53 listener.on_menu_mark_all_as_read_activate),
54 ("stop_refresh", None, _("_Stop Refresh"), None, _("Stop updating this feed"),
55 listener.on_menu_stop_poll_selected_activate),
56 ("remove", None, _("Remo_ve Feed"), None, _("Remove this feed from my subscription"),
57 listener.on_remove_selected_feed),
58 ("category-sort",None,_("_Arrange Feeds"), None, _("Sort the current category")),
59 ("ascending", gtk.STOCK_SORT_ASCENDING, _("Alpha_betical Order"), None, _("Sort in alphabetical order"),
60 listener.on_sort_ascending),
61 ("descending", gtk.STOCK_SORT_DESCENDING, _("Re_verse Order"), None, _("Sort in reverse order"),
62 listener.on_sort_descending),
63 ("properties", gtk.STOCK_INFO, _("_Information"), None, _("Feed-specific properties"),
64 listener.on_display_properties_feed)
66 ag = gtk.ActionGroup('FeedListPopupActions')
67 ag.add_actions(actions)
68 self.manager.insert_action_group(ag,0)
69 popupui = os.path.join(utils.find_image_dir(), 'ui.xml')
70 self.manager.add_ui_from_file(popupui)
72 @property
73 def popup(self):
74 return self.manager.get_widget('/feed_list_popup')
77 class FeedListModel:
78 ''' The model for the feed list view '''
80 def __init__(self):
81 # name, pixbuf, unread, foreground
82 self.store = gtk.TreeStore(gtk.gdk.Pixbuf, str, str, gobject.TYPE_PYOBJECT)
83 # unread, weight, status_flag feed object, allow_children
84 # str, int, 'gboolean', gobject.TYPE_PYOBJECT, 'gboolean'
86 self._init_model()
88 def __getattribute__(self, name):
89 attr = None
90 try:
91 attr = getattr(self, name)
92 except AttributeError, ae:
93 attr = getattr(self.store, name)
94 else:
95 return attr
97 def _init_model(self):
98 fclist = FeedCategoryList.get_instance()
99 parent = None
101 # build the user categories and its feeds
102 for category in fclist.user_categories:
103 parent = self.store.append(parent)
104 node_adapter = TreeNodeAdapter(category)
105 self.store.set(parent, Column.pixbuf, node_adapter.pixbuf,
106 Column.name, node_adapter.title,
107 Column.object, node_adapter)
108 for f in category.feeds:
109 rowiter = self.store.append(parent)
110 node_adapter = TreeNodeAdapter(f)
111 self.store.set(rowiter, Column.pixbuf, node_adapter.pixbuf,
112 Column.name, node_adapter.title,
113 Column.foreground, self.get_foreground(node_adapter.num_unread_items),
114 Column.object, node_adapter) # maybe use create_adapter(f) here?
116 # build the feeds that do not belong to any other category
117 for feed in fclist._un_category.feeds:
118 node_adapter = TreeNodeAdapter(feed)
119 self.store.append(None, [node_adapter.pixbuf, node_adapter.title,
120 self.get_foreground(node_adapter.num_unread_items),
121 node_adapter])
122 print "finished initializing feed list vie model"
124 def get_foreground(self, unread):
125 ''' gets the foreground color according to the number of unread items'''
126 return ('black', 'blue')[(unread > 0) and 1 or 0]
128 @property
129 def model(self):
130 return self.store
132 def get_selected_nodes(self, pathlist):
133 ''' Returns a list of feed objects that live in the pathlist '''
134 selected = []
135 for path in pathlist:
136 treeiter = self.store.get_iter(path)
137 object = self.store.get_value(treeiter, Column.object)
138 selected.append(object)
139 return selected
141 class FeedsView(MVP.WidgetView):
142 def _initialize(self):
143 self._widget.set_search_column(Column.name)
145 # pixbuf column
146 column = gtk.TreeViewColumn()
147 status_renderer = gtk.CellRendererPixbuf()
148 column.pack_start(status_renderer, False)
149 column.set_attributes(status_renderer,
150 pixbuf=Column.pixbuf)
152 # feed title renderer
153 title_renderer = gtk.CellRendererText()
154 column.pack_start(title_renderer, False)
155 column.set_attributes(title_renderer,
156 foreground=Column.foreground,
157 text=Column.name) #, weight=Column.BOLD)
159 self._widget.append_column(column)
161 selection = self._widget.get_selection()
162 selection.set_mode(gtk.SELECTION_MULTIPLE)
164 #selection.connect("changed", self._selection_changed)
166 self._widget.connect("button_press_event", self._on_button_press_event)
167 self._widget.connect("popup-menu", self._on_popup_menu)
169 self._popup = FeedListPopup(self).popup
171 def _model_set(self):
172 self._widget.set_model(self._model.model)
174 def add_selection_changed_listener(self, listener):
175 selection = self._widget.get_selection()
176 selection.connect('changed', listener.feedlist_selection_changed, self._model)
178 def _on_popup_menu(self, treeview, *args):
179 self._popup.popup(None, None, None, 0, 0)
182 def foreach_selected(self, func):
183 selection = self._widget.get_selection()
184 (model, pathlist) = selection.get_selected_rows()
185 iters = [model.get_iter(path) for path in pathlist]
186 for treeiter in iters:
187 object = model.get_value(treeiter, Column.object)
188 func(object, model, treeiter)
189 return
192 def _on_button_press_event(self, treeview, event):
193 retval = 0
194 if event.button == 3:
195 x = int(event.x)
196 y = int(event.y)
197 time = gtk.get_current_event_time()
198 path = treeview.get_path_at_pos(x, y)
199 if path is None:
200 return 1
201 path, col, cellx, celly = path
202 treeview.grab_focus()
203 self._popup.popup(None, None, None, event.button, time)
204 retval = 1
205 return retval
207 ### XXX don't do feed related tasks when path is a category
208 ### this applies to every operation I suppose
209 def on_menu_poll_selected_activate(self, *args):
210 config = Config.get_instance()
211 poll = True
212 if config.offline:
213 config.offline = not config.offline
214 # XXX pass 'list' of feeds
215 #PollManager.get_instance().poll([self._curr_feed])
216 return
219 def on_menu_stop_poll_selected_activate(self, *args):
220 self.foreach_selected(lambda o,*args: o.router.stop_polling())
222 def on_menu_mark_all_as_read_activate(self, *args):
223 self.foreach_selected(lambda o,*args: o.mark_all_read())
225 def on_remove_selected_feed(self, *args):
226 def remove(*args):
227 (object, model, treeiter) = args
228 model.remove(treeiter)
229 #feedlist = FeedList.get_instance()
230 #idx = feedlist.index(object)
231 #del feedlist[idx]
232 self.foreach_selected(remove)
233 return
236 def on_sort_ascending(self, *args):
237 ## XXX this will not work with tree list
238 self._presenter.sort_category()
240 def on_sort_descending(self, *args):
241 ### XXX this will not work with tree list
242 self._presenter.sort_category(reverse=True)
244 def on_display_properties_feed(self, *args):
245 self._presenter.show_feed_properties()
246 return
248 def select_first_feed(self):
249 treeiter = self._model.get_iter_first()
250 if not treeiter or not self._model.iter_is_valid(treeiter):
251 return False
252 self._view.set_cursor(treeiter)
253 return True
255 def select_next_feed(self, unread=False):
256 ''' Scrolls to the next feed in the feed list
258 If there is no selection, selects the first feed. If multiple feeds
259 are selected, selects the feed after the last selected feed.
261 If unread is True, selects the next unread with unread items.
263 If the selection next-to-be is a category, go to the iter its first
264 child. If current selection is a child, then go to (parent + 1),
265 provided that (parent + 1) is not a category.
267 def next(model, current):
268 treeiter = model.iter_next(current)
269 if not treeiter and model.iter_depth(current):
270 next(model, model.iter_parent(current))
271 self.set_cursor(treeiter)
272 selection = self._widget.get_selection()
273 (model, pathlist) = selection.get_selected_rows()
274 iters = [model.get_iter(path) for path in pathlist]
275 try:
276 current = iters.pop()
277 if model.iter_has_child(current):
278 iterchild = model.iter_children(current)
279 # make the row visible
280 path = model.get_path(iterchild)
281 for i in range(len(path)):
282 self._widget.expand_row(path[:i+1], False)
283 self.set_cursor(iterchild)
284 return
285 next(model,current)
286 except IndexError:
287 self.set_cursor(model.get_iter_first())
289 def select_previous_feed(self):
290 ''' Scrolls to the previous feed in the feed list.
292 If there is no selection, selects the first feed. If there's multiple
293 selection, selects the feed before the first selected feed.
295 If the previous selection is a category, select the last node in that
296 category. If the current selection is a child, then go to (parent -
297 1). If parent is the first feed, wrap and select the last feed or
298 category in the list.
300 def previous(model, current):
301 path = model.get_path(current)
302 path_len = len(path)
303 #path_prev = path[:path_len
304 print "\tpath is -> %s , prev_path -> %s", (path, prev_path)
305 treeiter = model.get_iter(prev_path)
306 self.set_cursor(treeiter)
307 selection = self._widget.get_selection()
308 (model, pathlist) = selection.get_selected_rows()
309 iters = [model.get_iter(path) for path in pathlist]
310 try:
311 current = iters.pop(0)
312 if model.iter_has_child(current):
313 kids = model.iter_n_children(current)
314 iter = model.iter_nth_child(kids - 1)
315 self.set_cursor(iter)
316 return
317 previous(model, current)
318 except IndexError:
319 self.set_cursor(model.get_iter_first())
321 def select_next_unread_feed(self):
322 has_unread = False
323 mark_treerow = 1
324 treerow = self._model[0]
325 selection = self._view.get_selection()
326 srow = selection.get_selected()
327 if srow:
328 model, treeiter = srow
329 nextiter = model.iter_next(treeiter)
330 if nextiter:
331 treerow = self._model[model.get_path(nextiter)]
332 while(treerow):
333 feedrow = treerow[Column.OBJECT]
334 if feedrow.feed.n_items_unread:
335 self._view.set_cursor(treerow.iter)
336 has_unread = True
337 break
338 treerow = treerow.next
339 if not treerow and mark_treerow:
340 # should only do this once.
341 mark_treerow = treerow
342 treerow = self._model[0]
343 return has_unread
346 def set_cursor(self, treeiter, col_id=None, edit=False):
347 if not treeiter:
348 return
349 column = None
350 path = self._model.model.get_path(treeiter)
351 if col_id:
352 column = self._widget.get_column(col_id)
353 self._widget.set_cursor(path, column, edit)
354 self._widget.scroll_to_cell(path, column)
355 self._widget.grab_focus()
356 return
358 class FeedsPresenter(MVP.BasicPresenter):
359 def _initialize(self):
360 self.model = FeedListModel()
361 # self._init_signals()
362 return
364 def _init_signals(self):
365 flist = feeds.get_instance()
366 #flist.signal_connect(Event.ItemReadSignal,
367 # self._feed_item_read)
368 #flist.signal_connect(Event.AllItemsReadSignal,
369 # self._feed_all_items_read)
370 #flist.signal_connect(Event.FeedsChangedSignal,
371 # self._feeds_changed)
372 #flist.signal_connect(Event.FeedDetailChangedSignal,
373 # self._feed_detail_changed)
374 #fclist = FeedCategoryList.get_instance()
375 #fclist.signal_connect(Event.FeedCategorySortedSignal,
376 # self._feeds_sorted_cb)
377 #fclist.signal_connect(Event.FeedCategoryChangedSignal,
378 # self._fcategory_changed_cb)
380 def select_next_feed(self, unread=False):
381 self.view.select_next_feed(unread)
383 def select_previous_feed(self):
384 self.view.select_previous_feed()
386 # def _sort_func(self, model, a, b):
388 Sorts the feeds lexically.
390 From the gtk.TreeSortable.set_sort_func doc:
392 The comparison callback should return -1 if the iter1 row should come before
393 the iter2 row, 0 if the rows are equal, or 1 if the iter1 row should come
394 after the iter2 row.
396 """ retval = 0
397 fa = model.get_value(a, Column.OBJECT)
398 fb = model.get_value(b, Column.OBJECT)
400 if fa and fb:
401 retval = locale.strcoll(fa.title, fb.title)
402 elif fa is not None: retval = -1
403 elif fb is not None: retval = 1
404 return retval
406 #def sort_category(self, reverse=False):
407 # self._curr_category.sort()
408 # if reverse:
409 # self._curr_category.reverse()
410 # return
412 def show_feed_properties(self):
413 FeedPropertiesDialog.show_feed_properties(None, self._curr_feed)
414 return
417 def _display_feeds(self, feeds, parent=None):
418 def _connect_adapter(adapter, feedindex):
419 adapter.signal_connect(Event.ItemsAddedSignal,
420 self._adapter_updated_handler, feedindex)
421 adapter.signal_connect(Event.FeedPolledSignal,
422 self._adapter_updated_handler, feedindex)
423 adapter.signal_connect(Event.FeedStatusChangedSignal,
424 self._adapter_updated_handler, feedindex)
425 adapter.signal_connect(Event.ItemReadSignal,
426 self._adapter_updated_handler, feedindex)
427 adapter.signal_connect(Event.AllItemsReadSignal,
428 self._adapter_updated_handler, feedindex)
429 curr_feed_iter = None
430 for f in feeds:
431 adapter = create_adapter(f)
432 rowiter = self._model.append(parent)
433 self._model.set(rowiter,
434 Column.NAME, adapter.title,
435 Column.OBJECT, adapter)
437 idx = self._curr_category.index_feed(adapter.feed)
438 _connect_adapter(adapter, idx) # we need this to get the rest of the data
439 new_string, new = adapter.unread
440 weight = (pango.WEIGHT_NORMAL, pango.WEIGHT_BOLD)[new > 0]
441 status, pixbuf = adapter.status_icon
442 self._model.set(rowiter,
443 Column.UNREAD, new_string,
444 Column.BOLD, weight,
445 Column.STATUS_FLAG, status,
446 Column.STATUS_PIXBUF, pixbuf,
447 Column.ALLOW_CHILDREN, False)
448 if adapter.feed is self._curr_feed:
449 curr_feed_iter = rowiter
450 return curr_feed_iter
452 def _disconnect(self, model, path, iter, user_data=None):
453 ob = model[path][Column.OBJECT]
454 self._disconnect_adapter(ob)
455 if not len(model):
456 return True
457 return False
459 def _disconnect_adapter(self, adapter):
460 adapter.disconnect()
461 adapter.signal_disconnect(Event.ItemsAddedSignal,
462 self._adapter_updated_handler)
463 adapter.signal_disconnect(Event.FeedPolledSignal,
464 self._adapter_updated_handler)
465 adapter.signal_disconnect(Event.FeedStatusChangedSignal,
466 self._adapter_updated_handler)
467 del adapter
469 def _adapter_updated_handler(self, signal, feed_index):
470 self._update_adapter_view(signal.sender, feed_index)
472 def _update_adapter_view(self, adapter, feed_index):
473 new = adapter.unread
474 row = self._model[feed_index]
475 row[Column.NAME] = adapter.title
476 row[Column.UNREAD] = new[0]
477 row[Column.BOLD] = (pango.WEIGHT_NORMAL, pango.WEIGHT_BOLD)[new[1] > 0]
478 row[Column.OBJECT] = adapter
479 status, pixbuf = adapter.status_icon
480 row[Column.STATUS_FLAG] = status
481 if pixbuf:
482 row[Column.STATUS_PIXBUF] = pixbuf
483 self._view.queue_draw()
485 def _feeds_changed(self, signal):
486 self._feed_view_update(signal.feed)
488 def _feed_detail_changed(self, signal):
489 self._feed_view_update(signal.sender)
491 def _feed_item_read(self, signal):
492 path = (0,)
493 selection = self._view.get_selection()
494 selected_row = selection.get_selected()
495 if selected_row:
496 model, treeiter = selected_row
497 path = model.get_path(treeiter)
498 treerow = self._model[path]
499 adapter = treerow[Column.OBJECT]
500 self._update_adapter_view(adapter, path)
502 def _feed_all_items_read(self, signal):
503 pass
505 def _feed_view_update(self, feed):
506 for index, f in enumerate(self._model):
507 adapter = self._model[index][Column.OBJECT]
508 if adapter.feed is feed:
509 self._update_adapter_view(adapter, index)
510 break
511 return
513 def _feeds_sorted_cb(self, signal):
514 self.model.set_sort_func(Column.NAME, self._sort_func)
515 if not signal.descending:
516 self.model.set_sort_column_id(Column.NAME, gtk.SORT_ASCENDING)
517 else:
518 self.model.set_sort_column_id(Column.NAME, gtk.SORT_DESCENDING)
519 self.model.sort_column_changed()
520 return
522 def _fcategory_changed_cb(self,signal):
523 if signal.sender is self._curr_category:
524 self._curr_category = signal.sender
525 self.display_category_feeds(self._curr_category)
526 return
528 def move_feed(self, sidx, tidx):
529 self._curr_category.move_feed(sidx, tidx)
530 return
532 class DisplayAdapter(object, Event.SignalEmitter):
534 def __init__(self, ob):
535 self._ob = ob
536 Event.SignalEmitter.__init__(self)
537 self.initialize_slots(Event.ItemReadSignal,
538 Event.ItemsAddedSignal,
539 Event.AllItemsReadSignal,
540 Event.FeedPolledSignal,
541 Event.FeedStatusChangedSignal)
543 def equals(self, ob):
544 return self._ob is ob
546 class FeedDisplayAdapter(DisplayAdapter):
548 def __init__(self, ob):
549 DisplayAdapter.__init__(self, ob)
550 ob.signal_connect(Event.ItemReadSignal, self.resend_signal)
551 ob.signal_connect(Event.ItemsAddedSignal, self.resend_signal)
552 ob.signal_connect(Event.AllItemsReadSignal, self.resend_signal)
553 ob.signal_connect(Event.FeedPolledSignal, self.resend_signal)
554 ob.signal_connect(Event.FeedStatusChangedSignal, self.resend_signal)
556 def disconnect(self):
557 self._ob.signal_disconnect(
558 Event.ItemReadSignal, self.resend_signal)
559 self._ob.signal_disconnect(
560 Event.ItemsAddedSignal, self.resend_signal)
561 self._ob.signal_disconnect(
562 Event.AllItemsReadSignal, self.resend_signal)
563 self._ob.signal_disconnect(
564 Event.FeedPolledSignal, self.resend_signal)
565 self._ob.signal_disconnect(
566 Event.FeedStatusChangedSignal, self.resend_signal)
568 def resend_signal(self, signal):
569 new = copy.copy(signal)
570 new.sender = self
571 self.emit_signal(new)
573 @property
574 def title(self):
575 return self._ob.title
577 @property
578 def unread(self):
579 nu = self._ob.n_items_unread
580 if nu != 0:
581 return ("%s" % nu, nu)
582 else:
583 return ("", nu)
585 @property
586 def status_icon(self):
587 if self._ob.process_status is not Feed.Feed.STATUS_IDLE:
588 return (1, gtk.STOCK_EXECUTE)
589 elif self._ob.error:
590 return (1, gtk.STOCK_DIALOG_ERROR)
591 return (0, None)
593 @property
594 def feed(self):
595 return self._ob
597 contents = property(lambda x: None)
598 open = property(lambda x: None)
601 class TreeNodeAdapter:
602 ''' A Node Adapter which encapsulates either a Category or a Feed '''
604 def __init__(self, object):
605 self.obj = object
606 filename = os.path.join(utils.find_image_dir(), 'feed.png')
607 self.default_pixbuf = gtk.gdk.pixbuf_new_from_file(filename)
609 def has_children(self):
610 ''' Checks if the node has children. Essentially this means the object
611 is a category.'''
612 has_child = False
613 try:
614 has_child = self.obj.feeds and True or False
615 except AttributeError:
616 has_child = False
617 return has_child
619 @property
620 def title(self):
621 ''' The title of the node be it a category or a feed '''
622 return self.obj.title
624 @property
625 def num_unread_items(self):
626 ''' The number of unread items of the feed or if it's a category,
627 the aggregate number of unread items of the feeds belonging to the
628 category.'''
629 unread_items = 0
630 try:
631 unread_items = self.obj.n_items_unread
632 except AttributeError:
633 unread_items = reduce(lambda a,b: a.n_items_unread + b.n_items_unread,
634 self.obj.feeds)
635 print "number of unread of category is", unread_items
636 return unread_items
638 @property
639 def pixbuf(self):
640 ''' gets the pixbuf to display according to the status of the feed '''
641 widget = gtk.Label()
642 # ignore why above is a gtk.Label. We just need
643 # gtk.Widget.render_icon to get a pixbuf out of a stock image.
644 # Fyi, gtk.Widget is abstract that is why we need a subclass to do
645 # this for us.
646 try:
647 if self.obj.process_status is not feeds.Feed.STATUS_IDLE:
648 return widget.render_icon(gtk.STOCK_EXECUTE. gtk.ICON_SIZE_MENU)
649 elif self.obj.error:
650 return widget.render_icon(gtk.STOCK_DIALOG_ERROR, gtk.ICON_SIZE_MENU)
651 except AttributeError:
652 return widget.render_icon(gtk.STOCK_DIRECTORY, gtk.ICON_SIZE_MENU)
653 return self.default_pixbuf
655 @property
656 def feed(self):
657 ''' An alias to a Feed object '''
658 if not isinstance(self.obj, feeds.Feed):
659 raise TypeError, _("object is not of a Feed")
660 return self.obj
662 @property
663 def category(self):
664 ''' An alias to a Category object '''
665 if not isinstance(self.obj, FeedCategoryList.FeedCategory):
666 raise TypeError, _("object is not a Category")
667 return self.obj