Fixed item navigation and cleaned up feed and item changes
[straw.git] / src / lib / FeedListView.py
blobb7cc76483387fb337da84b64f3f406a94d55b7af
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, unread, object = range(5)
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 def _r(a,b):return a + b
104 try:
105 unread_items = self.obj.number_of_unread
106 except AttributeError:
107 unread_items = reduce(_r, [feed.number_of_unread for feed in self.obj.feeds])
108 return unread_items or ''
110 @property
111 def pixbuf(self):
112 ''' gets the pixbuf to display according to the status of the feed '''
113 widget = gtk.Label()
114 # ignore why above is a gtk.Label. We just need
115 # gtk.Widget.render_icon to get a pixbuf out of a stock image.
116 # Fyi, gtk.Widget is abstract that is why we need a subclass to do
118 # this for us.
119 try:
120 if self.obj.process_status is not feeds.Feed.STATUS_IDLE:
121 return widget.render_icon(gtk.STOCK_EXECUTE, gtk.ICON_SIZE_MENU)
122 elif self.obj.error:
123 return widget.render_icon(gtk.STOCK_DIALOG_ERROR, gtk.ICON_SIZE_MENU)
124 except AttributeError, ex:
125 print ex
126 return widget.render_icon(gtk.STOCK_DIRECTORY, gtk.ICON_SIZE_MENU)
127 return self.default_pixbuf
129 @property
130 def feed(self):
131 ''' An alias to a Feed object '''
132 if not isinstance(self.obj, feeds.Feed):
133 raise TypeError, _("object is not of a Feed")
134 return self.obj
136 @property
137 def category(self):
138 ''' An alias to a Category object '''
139 if not isinstance(self.obj, FeedCategoryList.FeedCategory):
140 raise TypeError, _("object is not a Category")
141 return self.obj
144 class FeedListPopup:
145 ''' Abstracts a popup widget '''
147 def __init__(self, listener):
148 self.manager = gtk.UIManager()
149 actions = [
150 ("refresh", gtk.STOCK_REFRESH, _("_Refresh"), None, _("Update this feed"),
151 listener.on_menu_poll_selected_activate),
152 ("mark_as_read", gtk.STOCK_CLEAR, _("_Mark As Read"), None, _("Mark all items in this feed as read"),
153 listener.on_menu_mark_all_as_read_activate),
154 ("stop_refresh", None, _("_Stop Refresh"), None, _("Stop updating this feed"),
155 listener.on_menu_stop_poll_selected_activate),
156 ("remove", None, _("Remo_ve Feed"), None, _("Remove this feed from my subscription"),
157 listener.on_remove_selected_feed),
158 ("category-sort",None,_("_Arrange Feeds"), None, _("Sort the current category")),
159 ("ascending", gtk.STOCK_SORT_ASCENDING, _("Alpha_betical Order"), None, _("Sort in alphabetical order"),
160 listener.on_sort_ascending),
161 ("descending", gtk.STOCK_SORT_DESCENDING, _("Re_verse Order"), None, _("Sort in reverse order"),
162 listener.on_sort_descending),
163 ("properties", gtk.STOCK_INFO, _("_Information"), None, _("Feed-specific properties"),
164 listener.on_display_properties_feed)
166 ag = gtk.ActionGroup('FeedListPopupActions')
167 ag.add_actions(actions)
168 self.manager.insert_action_group(ag,0)
169 popupui = os.path.join(utils.find_image_dir(), 'ui.xml')
170 self.manager.add_ui_from_file(popupui)
172 @property
173 def popup(self):
174 return self.manager.get_widget('/feed_list_popup')
177 class FeedListModel:
178 ''' The model for the feed list view '''
180 def __init__(self):
181 # name, pixbuf, unread, foreground
182 self.store = gtk.TreeStore(gtk.gdk.Pixbuf, str, str, str, gobject.TYPE_PYOBJECT)
183 # unread, weight, status_flag feed object, allow_children
184 # str, int, 'gboolean', gobject.TYPE_PYOBJECT, 'gboolean'
185 self.fclist = FeedCategoryList.get_instance()
187 # build the user categories and its feeds
188 populate = self.populate
189 for category in self.fclist.user_categories:
190 populate(category, category.feeds)
191 populate(None, self.fclist.un_category.feeds)
193 def populate(self, category, feeds):
194 parent = None
195 if category:
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.unread, node_adapter.num_unread_items,
204 Column.object, node_adapter)
205 for f in feeds:
206 rowiter = self.store.append(parent)
207 node_adapter = TreeNodeAdapter(f)
208 node_adapter.connect('feed-changed', self.feed_changed_cb)
209 self.store.set(rowiter, Column.pixbuf, node_adapter.pixbuf,
210 Column.name, node_adapter.title,
211 Column.foreground, node_adapter.num_unread_items and 'blue' or 'black',
212 Column.unread, node_adapter.num_unread_items,
213 Column.object, node_adapter)
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,
237 feed_node_adapter.title,
238 feed_node_adapter.num_unread_items and 'blue' or 'black',
239 feed_node_adapter.num_unread_items,
240 feed_node_adapter])
242 def feed_removed_cb(self, node_adapter, feed):
243 print "FEED REMOVED CB ", node_adapter, feed
245 def feed_changed_cb(self, node_adapter, feed):
246 print "FEED CHANGED ", node_adapter, feed
247 row = self.search(self.store,
248 lambda r, data: r[data[0]] == data[1],
249 (Column.object, node_adapter))
250 if not row: return
251 path = self.store.get_path(row.iter)
252 self.store[path] = [node_adapter.pixbuf, node_adapter.title,
253 node_adapter.num_unread_items and 'blue' or 'black',
254 node_adapter.num_unread_items,
255 node_adapter]
257 @property
258 def model(self):
259 return self.store
261 def search(self, rows, func, data):
262 if not rows: return None
263 for row in rows:
264 if func(row, data):
265 return row
266 result = self.search(row.iterchildren(), func, data)
267 if result: return result
268 return None
270 class FeedsView(MVP.WidgetView):
271 def _initialize(self):
272 self._widget.set_search_column(Column.name)
274 # pixbuf column
275 column = gtk.TreeViewColumn()
276 status_renderer = gtk.CellRendererPixbuf()
277 column.pack_start(status_renderer, False)
278 column.set_attributes(status_renderer,
279 pixbuf=Column.pixbuf)
281 unread_renderer = gtk.CellRendererText()
282 column.pack_start(unread_renderer, False)
283 column.set_attributes(unread_renderer,
284 text=Column.unread)
286 # feed title renderer
287 title_renderer = gtk.CellRendererText()
288 column.pack_start(title_renderer, False)
289 column.set_attributes(title_renderer,
290 foreground=Column.foreground,
291 text=Column.name) #, weight=Column.BOLD)
293 self._widget.append_column(column)
295 selection = self._widget.get_selection()
296 selection.set_mode(gtk.SELECTION_MULTIPLE)
298 self._widget.connect("button_press_event", self._on_button_press_event)
299 self._widget.connect("popup-menu", self._on_popup_menu)
301 self._popup = FeedListPopup(self).popup
303 def _model_set(self):
304 self._widget.set_model(self._model.model)
306 def add_selection_changed_listener(self, listener):
307 selection = self._widget.get_selection()
308 selection.connect('changed', listener.feedlist_selection_changed, Column.object)
310 def _on_popup_menu(self, treeview, *args):
311 self._popup.popup(None, None, None, 0, 0)
313 def foreach_selected(self, func):
314 selection = self._widget.get_selection()
315 (model, pathlist) = selection.get_selected_rows()
316 iters = [model.get_iter(path) for path in pathlist]
317 try:
318 for treeiter in iters:
319 object = model.get_value(treeiter, Column.object)
320 func(object, model, treeiter)
321 except TypeError, te:
322 ## XXX maybe object is a category
323 print te
324 return
326 def _on_button_press_event(self, treeview, event):
327 retval = 0
328 if event.button == 3:
329 x = int(event.x)
330 y = int(event.y)
331 time = gtk.get_current_event_time()
332 path = treeview.get_path_at_pos(x, y)
333 if path is None:
334 return 1
335 path, col, cellx, celly = path
336 treeview.grab_focus()
337 self._popup.popup(None, None, None, event.button, time)
338 retval = 1
339 return retval
341 ### XXX don't do feed related tasks when path is a category this applies to every operation I suppose
342 def on_sort_ascending(self, *args):
343 ## XXX this will not work with tree list
344 self._presenter.sort_category()
346 def on_sort_descending(self, *args):
347 ### XXX this will not work with tree list
348 self._presenter.sort_category(reverse=True)
350 def on_menu_poll_selected_activate(self, *args):
351 config = Config.get_instance()
352 poll = True
353 if config.offline: #XXX
354 config.offline = not config.offline
355 selection = self._widget.get_selection()
356 (model, pathlist) = selection.get_selected_rows()
357 iters = [model.get_iter(path) for path in pathlist]
358 feeds = [o.feed for o in [model.get_value(treeiter,Column.object) for treeiter in iters]]
359 PollManager.get_instance().poll(feeds)
360 return
362 def on_menu_stop_poll_selected_activate(self, *args):
363 self.foreach_selected(lambda o,*args: o.feed.router.stop_polling())
365 def on_menu_mark_all_as_read_activate(self, *args):
366 self.foreach_selected(lambda o,*args: o.feed.mark_items_as_read())
368 def on_remove_selected_feed(self, *args):
369 def remove(*args):
370 (object, model, treeiter) = args
371 model.remove(treeiter)
372 feedlist = feeds.get_instance()
373 idx = feedlist.index(object.feed)
374 del feedlist[idx]
375 self.foreach_selected(remove)
376 return
378 def on_display_properties_feed(self, *args):
379 selection = self._widget.get_selection()
380 (model, pathlist) = selection.get_selected_rows()
381 iters = [model.get_iter(path) for path in pathlist]
382 path = pathlist.pop()
383 node_adapter = self.model.model[path][Column.object]
384 self._presenter.show_feed_information(node_adapter.feed)
385 return
387 def select_first_feed(self):
388 selection = self._widget.get_selection()
389 (model, pathlist) = selection.get_selected_rows()
390 treeiter = model.get_iter_first()
391 if not treeiter or not model.iter_is_valid(treeiter):
392 return False
393 self.set_cursor(treeiter)
394 return True
396 def select_next_feed(self, with_unread=False):
397 ''' Scrolls to the next feed in the feed list
399 If there is no selection, selects the first feed. If multiple feeds
400 are selected, selects the feed after the last selected feed.
402 If unread is True, selects the next unread with unread items.
404 If the selection next-to-be is a category, go to the iter its first
405 child. If current selection is a child, then go to (parent + 1),
406 provided that (parent + 1) is not a category.
408 has_unread = False
409 def next(model, current):
410 treeiter = model.iter_next(current)
411 if not treeiter: return False
412 if model.iter_depth(current): next(model, model.iter_parent(current))
413 path = model.get_path(treeiter)
414 if with_unread and model[path][Column.unread] < 1:
415 next(model, current)
416 self.set_cursor(treeiter)
417 return True
418 selection = self._widget.get_selection()
419 (model, pathlist) = selection.get_selected_rows()
420 iters = [model.get_iter(path) for path in pathlist]
421 try:
422 current = iters.pop()
423 if model.iter_has_child(current):
424 iterchild = model.iter_children(current)
425 # make the row visible
426 path = model.get_path(iterchild)
427 for i in range(len(path)):
428 self._widget.expand_row(path[:i+1], False)
429 # select his first born child
430 if with_unread and model[path][Column.unread] > 0:
431 self.set_cursor(iterchild)
432 has_unread = True
433 else:
434 has_unread = next(model, current)
435 has_unread = next(model,current)
436 except IndexError:
437 self.set_cursor(model.get_iter_first())
438 has_unread = True
439 print "HAS UNREAD ", has_unread
440 return has_unread
442 def select_previous_feed(self):
443 ''' Scrolls to the previous feed in the feed list.
445 If there is no selection, selects the first feed. If there's multiple
446 selection, selects the feed before the first selected feed.
448 If the previous selection is a category, select the last node in that
449 category. If the current selection is a child, then go to (parent -
450 1). If parent is the first feed, wrap and select the last feed or
451 category in the list.
453 def previous(model, current):
454 path = model.get_path(current)
455 treerow = model[path[-1]-1]
456 self.set_cursor(treerow.iter)
457 selection = self._widget.get_selection()
458 (model, pathlist) = selection.get_selected_rows()
459 iters = [model.get_iter(path) for path in pathlist]
460 try:
461 current_first = iters.pop(0)
462 if model.iter_has_child(current_first):
463 children = model.iter_n_children(current_first)
464 treeiter = model.iter_nth_child(children - 1)
465 self.set_cursor(treeiter)
466 return
467 previous(model, current_first)
468 except IndexError:
469 self.set_cursor(model.get_iter_first())
470 return
472 def set_cursor(self, treeiter, col_id=None, edit=False):
473 if not treeiter:
474 return
475 column = None
476 path = self._model.model.get_path(treeiter)
477 if col_id:
478 column = self._widget.get_column(col_id)
479 self._widget.set_cursor(path, column, edit)
480 self._widget.scroll_to_cell(path, column)
481 self._widget.grab_focus()
482 return
484 class FeedsPresenter(MVP.BasicPresenter):
485 def _initialize(self):
486 self.model = FeedListModel()
487 # self._init_signals()
488 return
490 def _init_signals(self):
491 flist = feeds.get_instance()
492 #flist.signal_connect(Event.ItemReadSignal,
493 # self._feed_item_read)
494 #flist.signal_connect(Event.AllItemsReadSignal,
495 # self._feed_all_items_read)
496 #flist.signal_connect(Event.FeedsChangedSignal,
497 # self._feeds_changed)
498 #flist.signal_connect(Event.FeedDetailChangedSignal,
499 # self._feed_detail_changed)
500 #fclist = FeedCategoryList.get_instance()
501 #fclist.signal_connect(Event.FeedCategorySortedSignal,
502 # self._feeds_sorted_cb)
503 #fclist.signal_connect(Event.FeedCategoryChangedSignal,
504 # self._fcategory_changed_cb)
506 def select_first_feed(self):
507 return self.view.select_first_feed()
509 def select_next_feed(self, with_unread=False):
510 return self.view.select_next_feed(with_unread)
512 def select_previous_feed(self):
513 return self.view.select_previous_feed()
515 def _sort_func(self, model, a, b):
517 Sorts the feeds lexically.
519 From the gtk.TreeSortable.set_sort_func doc:
521 The comparison callback should return -1 if the iter1 row should come before
522 the iter2 row, 0 if the rows are equal, or 1 if the iter1 row should come
523 after the iter2 row.
525 retval = 0
526 fa = model.get_value(a, Column.OBJECT)
527 fb = model.get_value(b, Column.OBJECT)
529 if fa and fb:
530 retval = locale.strcoll(fa.title, fb.title)
531 elif fa is not None: retval = -1
532 elif fb is not None: retval = 1
533 return retval
535 #def sort_category(self, reverse=False):
536 # self._curr_category.sort()
537 # if reverse:
538 # self._curr_category.reverse()
539 # return
541 def show_feed_information(self, feed):
542 FeedPropertiesDialog.show_feed_properties(None, feed)
543 return