feed list treeview alpha 20-05-07
[straw.git] / src / lib / FeedListView.py
blob3c02cf775ea7ad64ac9789af503b57ef6244b8ea
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 FeedList
32 import Feed
33 import Event
34 import dialogs
35 import Config
36 import PollManager
37 import MVP
39 import utils
41 class Column:
43 pixbuf, name, foreground, object = range(4)
44 # UNREAD = 1
45 # BOLD = 2
46 # STATUS_FLAG = 3 <- is it polling? processing images?
47 # OBJECT = 5
48 # ALLOW_CHILDREN = 6
50 class FeedListPopup:
51 ''' Abstracts a popup widget '''
53 def __init__(self, listener):
54 self.manager = gtk.UIManager()
55 actions = [
56 ("refresh", gtk.STOCK_REFRESH, _("_Refresh"), None, _("Update this feed"),
57 listener.on_menu_poll_selected_activate),
58 ("mark_as_read", gtk.STOCK_CLEAR, _("_Mark As Read"), None, _("Mark all items in this feed as read"),
59 listener.on_menu_mark_all_as_read_activate),
60 ("stop_refresh", None, _("_Stop Refresh"), None, _("Stop updating this feed"),
61 listener.on_menu_stop_poll_selected_activate),
62 ("remove", None, _("Remo_ve Feed"), None, _("Remove this feed from my subscription"),
63 listener.on_remove_selected_feed),
64 ("category-sort",None,_("_Arrange Feeds"), None, _("Sort the current category")),
65 ("ascending", gtk.STOCK_SORT_ASCENDING, _("Alpha_betical Order"), None, _("Sort in alphabetical order"),
66 listener.on_sort_ascending),
67 ("descending", gtk.STOCK_SORT_DESCENDING, _("Re_verse Order"), None, _("Sort in reverse order"),
68 listener.on_sort_descending),
69 ("properties", gtk.STOCK_INFO, _("_Information"), None, _("Feed-specific properties"),
70 listener.on_display_properties_feed)
72 ag = gtk.ActionGroup('FeedListPopupActions')
73 ag.add_actions(actions)
74 self.manager.insert_action_group(ag,0)
75 popupui = os.path.join(utils.find_image_dir(), 'ui.xml')
76 self.manager.add_ui_from_file(popupui)
78 @property
79 def popup(self):
80 return self.manager.get_widget('/feed_list_popup')
83 class FeedListModel:
84 ''' The model for the feed list view '''
86 def __init__(self):
87 # name, pixbuf, unread, foreground
88 self.store = gtk.TreeStore(gtk.gdk.Pixbuf, str, str, gobject.TYPE_PYOBJECT)
89 # unread, weight, status_flag feed object, allow_children
90 # str, int, 'gboolean', gobject.TYPE_PYOBJECT, 'gboolean'
91 filename = os.path.join(utils.find_image_dir(), 'feed.png')
92 self.pixbuf = gtk.gdk.pixbuf_new_from_file(filename)
94 self._init_model()
96 def _init_model(self):
97 fclist = FeedCategoryList.get_instance()
98 parent = None
100 # build the user categories and its feeds
101 for category in fclist.user_categories:
102 parent = self.store.append(parent)
103 self.store.set(parent, Column.pixbuf, self.pixbuf,
104 Column.name, category.title,
105 Column.object, None)
106 for f in category.feeds:
107 rowiter = self.store.append(parent)
108 self.store.set(rowiter, Column.pixbuf, self.get_pixbuf(f),
109 Column.name, f.title,
110 Column.foreground, self.get_foreground(f.n_items_unread),
111 Column.object, f) # maybe use create_adapter(f) here?
113 # build the feeds that do not belong to any other category
114 for feed in fclist._un_category.feeds:
115 self.store.append(None, [self.get_pixbuf(feed), feed.title,
116 self.get_foreground(feed.n_items_unread),
117 feed])
119 print "finished initializing feed list vie model"
121 def get_foreground(self, unread):
122 ''' gets the foreground color according to the number of unread items'''
123 return ('black', 'blue')[(unread > 0) and 1 or 0]
125 def get_pixbuf(self, feed):
126 ''' gets the pixbuf to display according to the status of the feed '''
127 widget = gtk.Label()
128 # ignore why above is a gtk.Label. We just need
129 # gtk.Widget.render_icon to get a pixbuf out of a stock image.
130 # Fyi, gtk.Widget is abstract that is why we need a subclass to do
131 # this for us.
132 if feed.process_status is not Feed.Feed.STATUS_IDLE:
133 return widget.render_icon(gtk.STOCK_EXECUTE. gtk.ICON_SIZE_MENU)
134 elif feed.error:
135 return widget.render_icon(gtk.STOCK_DIALOG_ERROR, gtk.ICON_SIZE_MENU)
136 else:
137 return self.pixbuf
139 @property
140 def model(self):
141 return self.store
143 def get_feeds(self, pathlist):
144 ''' Returns a list of feed objects that live in the pathlist '''
145 selected_feeds = []
146 for path in pathlist:
147 treeiter = self.store.get_iter(path)
148 object = self.store.get_value(treeiter, Column.object)
149 if object:
150 selected_feeds.append(object)
151 return selected_feeds
154 class FeedsView(MVP.WidgetView):
155 def _initialize(self):
156 self._widget.set_search_column(Column.name)
158 # pixbuf column
159 column = gtk.TreeViewColumn()
160 status_renderer = gtk.CellRendererPixbuf()
161 column.pack_start(status_renderer, False)
162 column.set_attributes(status_renderer,
163 pixbuf=Column.pixbuf)
165 # feed title renderer
166 title_renderer = gtk.CellRendererText()
167 column.pack_start(title_renderer, False)
168 column.set_attributes(title_renderer,
169 foreground=Column.foreground,
170 text=Column.name) #, weight=Column.BOLD)
172 # unread count
173 #unread_renderer = gtk.CellRendererText()
174 #column.pack_start(unread_renderer, False)
175 #column.set_attributes(unread_renderer,
176 # text=Column.unread)
178 self._widget.append_column(column)
180 selection = self._widget.get_selection()
181 selection.set_mode(gtk.SELECTION_MULTIPLE)
183 selection.connect("changed", self._selection_changed)
185 self._widget.connect("button_press_event", self._on_button_press_event)
186 self._widget.connect("popup-menu", self._on_popup_menu)
188 self._popup = FeedListPopup(self).popup
190 def _model_set(self):
191 self._widget.set_model(self._model.model)
193 def add_selection_changed_listener(self, listener):
194 selection = self._widget.get_selection()
195 selection.connect('changed', listener.feedlist_selection_changed, self._model)
197 def _on_popup_menu(self, treeview, *args):
198 self._popup.popup(None, None, None, 0, 0)
201 def foreach_selected(self, func):
202 selection = self._widget.get_selection()
203 (model, pathlist) = selection.get_selected_rows()
204 iters = [model.get_iter(path) for path in pathlist]
205 for treeiter in iters:
206 object = model.get_value(treeiter, Column.object)
207 func(object, model, treeiter)
208 return
211 def _on_button_press_event(self, treeview, event):
212 retval = 0
213 if event.button == 3:
214 x = int(event.x)
215 y = int(event.y)
216 time = gtk.get_current_event_time()
217 path = treeview.get_path_at_pos(x, y)
218 if path is None:
219 return 1
220 path, col, cellx, celly = path
221 treeview.grab_focus()
222 self._popup.popup(None, None, None, event.button, time)
223 retval = 1
224 return retval
226 def on_menu_poll_selected_activate(self, *args):
227 config = Config.get_instance()
228 poll = True
229 if config.offline:
230 response = dialogs.report_offline_status()
231 if not (response == gtk.RESPONSE_OK):
232 #config.offline = not config.offline
233 poll = False
234 return
235 else:
236 config.offline = not config.offline
237 #PollManager.get_instance().poll([self._curr_feed])
238 return
241 def on_menu_stop_poll_selected_activate(self, *args):
242 self.foreach_selected(lambda o,*args: o.router.stop_polling())
244 def on_menu_mark_all_as_read_activate(self, *args):
245 ### XXX don't do feed related tasks when path is a category
246 ### this applies to every operation I suppose
247 #def mark_read(iter, object): object.mark_all_read()
248 self.foreach_selected(lambda o,*args: o.mark_all_read())
250 def on_remove_selected_feed(self, *args):
251 def remove(*args):
252 (object, model, treeiter) = args
253 model.remove(treeiter)
254 #feedlist = FeedList.get_instance()
255 #idx = feedlist.index(object)
256 #del feedlist[idx]
257 self.foreach_selected(remove)
258 return
261 def on_sort_ascending(self, *args):
262 ## XXX this will not work with tree list
263 self._presenter.sort_category()
265 def on_sort_descending(self, *args):
266 ### XXX this will not work with tree list
267 self._presenter.sort_category(reverse=True)
269 def on_display_properties_feed(self, *args):
270 self._presenter.show_feed_properties()
271 return
273 def _selection_changed(self, selection):
275 Called when the current feed selection changed
277 (model, pathlist) = selection.get_selected_rows()
278 print "feed list view ", pathlist
279 #model, rowiter = selection.get_selected()
280 #if not rowiter:
281 # return
282 #object = model.get_value(rowiter, column)
283 #if object:
284 #self._presenter.selection_changed(None) #object
285 return
287 #def set_cursor(self, treeiter, col_id=None, edit=False):
288 # path = self._model.get_path(treeiter)
289 # if col_id:
290 # column = self._widget.get_column(col_id)
291 # else:
292 # column = None
293 # self._widget.set_cursor(path, column, edit)
294 # self._widget.scroll_to_cell(path, column)
295 # self._widget.grab_focus()
296 # return
299 #def get_location(self):
300 # model, iter = self._widget.get_selection().get_selected()
301 # if iter is None:
302 # return (None, None)
303 # path = model.get_path(iter)
304 # return self.get_parent_with_path(FeedList.get_instance(), path)
306 class FeedsPresenter(MVP.BasicPresenter):
307 def _initialize(self):
308 self.model = FeedListModel()
309 # self._init_signals()
310 # self._curr_feed = None
311 # self._curr_category = None
312 return
315 def _init_signals(self):
316 flist = FeedList.get_instance()
317 flist.signal_connect(Event.ItemReadSignal,
318 self._feed_item_read)
319 flist.signal_connect(Event.AllItemsReadSignal,
320 self._feed_all_items_read)
321 flist.signal_connect(Event.FeedsChangedSignal,
322 self._feeds_changed)
323 flist.signal_connect(Event.FeedDetailChangedSignal,
324 self._feed_detail_changed)
325 fclist = FeedCategoryList.get_instance()
326 fclist.signal_connect(Event.FeedCategorySortedSignal,
327 self._feeds_sorted_cb)
328 fclist.signal_connect(Event.FeedCategoryChangedSignal,
329 self._fcategory_changed_cb)
331 # def _sort_func(self, model, a, b):
333 Sorts the feeds lexically.
335 From the gtk.TreeSortable.set_sort_func doc:
337 The comparison callback should return -1 if the iter1 row should come before
338 the iter2 row, 0 if the rows are equal, or 1 if the iter1 row should come
339 after the iter2 row.
341 """ retval = 0
342 fa = model.get_value(a, Column.OBJECT)
343 fb = model.get_value(b, Column.OBJECT)
345 if fa and fb:
346 retval = locale.strcoll(fa.title, fb.title)
347 elif fa is not None: retval = -1
348 elif fb is not None: retval = 1
349 return retval
351 def get_selected_feed(self):
352 return self._curr_feed
355 def sort_category(self, reverse=False):
356 self._curr_category.sort()
357 if reverse:
358 self._curr_category.reverse()
359 return
361 def show_feed_properties(self):
362 FeedPropertiesDialog.show_feed_properties(None, self._curr_feed)
363 return
365 def select_first_feed(self):
366 treeiter = self._model.get_iter_first()
367 if not treeiter or not self._model.iter_is_valid(treeiter):
368 return False
369 self._view.set_cursor(treeiter)
370 return True
372 # def select_next_feed(self):
374 # Scrolls to the next feed in the feed list
376 """ selection = self._view.get_selection()
377 model, treeiter = selection.get_selected()
378 if not treeiter:
379 treeiter = model.get_iter_first()
380 next_feed_iter = model.iter_next(treeiter)
381 if not next_feed_iter or not self._model.iter_is_valid(next_feed_iter):
382 return False
383 self._view.set_cursor(next_feed_iter)
384 return True
386 def select_previous_feed(self):
388 Scrolls to the previous feeds in the feed list.
390 selection = self._view.get_selection()
391 model, treeiter = selection.get_selected()
392 if not treeiter:
393 treeiter = model.get_iter_first()
394 path = model.get_path(treeiter)
395 # check if there's a feed in the path
396 if not path:
397 return False
398 prev_path = path[-1]-1
399 if prev_path < 0:
400 # go to the last feed in the list
401 prev_path = len(self._model) - 1
402 self._view.set_cursor(self._model.get_iter(prev_path))
403 return True
405 def select_next_unread_feed(self):
406 has_unread = False
407 mark_treerow = 1
408 treerow = self._model[0]
409 selection = self._view.get_selection()
410 srow = selection.get_selected()
411 if srow:
412 model, treeiter = srow
413 nextiter = model.iter_next(treeiter)
414 if nextiter:
415 treerow = self._model[model.get_path(nextiter)]
416 while(treerow):
417 feedrow = treerow[Column.OBJECT]
418 if feedrow.feed.n_items_unread:
419 self._view.set_cursor(treerow.iter)
420 has_unread = True
421 break
422 treerow = treerow.next
423 if not treerow and mark_treerow:
424 # should only do this once.
425 mark_treerow = treerow
426 treerow = self._model[0]
427 return has_unread
429 def display_feed(self, feed):
430 # set_cursor will emit a 'changed' event in the treeview
431 # and then feed_selection_changed above will be called.
432 path = self._curr_category.index_feed(feed)
433 treeiter = self._model.get_iter(path)
434 self._view.set_cursor(treeiter, Column.NAME)
436 def display_category_feeds(self, category):
437 self._curr_category = category
438 feeds = self._curr_category.feeds
439 self._model.foreach(self._disconnect)
440 self._model.clear()
441 curr_feed_iter = self._display_feeds(feeds)
442 if curr_feed_iter:
443 self._view.set_cursor(curr_feed_iter)
444 else:
445 it = self._model.get_iter_first()
446 if it:
447 self._view.set_cursor(it)
448 else:
449 self.emit_signal(Event.FeedsEmptySignal(self))
450 return
452 def _display_feeds(self, feeds, parent=None):
453 def _connect_adapter(adapter, feedindex):
454 adapter.signal_connect(Event.ItemsAddedSignal,
455 self._adapter_updated_handler, feedindex)
456 adapter.signal_connect(Event.FeedPolledSignal,
457 self._adapter_updated_handler, feedindex)
458 adapter.signal_connect(Event.FeedStatusChangedSignal,
459 self._adapter_updated_handler, feedindex)
460 adapter.signal_connect(Event.ItemReadSignal,
461 self._adapter_updated_handler, feedindex)
462 adapter.signal_connect(Event.AllItemsReadSignal,
463 self._adapter_updated_handler, feedindex)
464 curr_feed_iter = None
465 for f in feeds:
466 adapter = create_adapter(f)
467 rowiter = self._model.append(parent)
468 self._model.set(rowiter,
469 Column.NAME, adapter.title,
470 Column.OBJECT, adapter)
472 idx = self._curr_category.index_feed(adapter.feed)
473 _connect_adapter(adapter, idx) # we need this to get the rest of the data
474 new_string, new = adapter.unread
475 weight = (pango.WEIGHT_NORMAL, pango.WEIGHT_BOLD)[new > 0]
476 status, pixbuf = adapter.status_icon
477 self._model.set(rowiter,
478 Column.UNREAD, new_string,
479 Column.BOLD, weight,
480 Column.STATUS_FLAG, status,
481 Column.STATUS_PIXBUF, pixbuf,
482 Column.ALLOW_CHILDREN, False)
483 if adapter.feed is self._curr_feed:
484 curr_feed_iter = rowiter
485 return curr_feed_iter
487 def display_empty_category(self):
488 self._model.foreach(self._disconnect)
489 self._model.clear()
490 return
492 def _disconnect(self, model, path, iter, user_data=None):
493 ob = model[path][Column.OBJECT]
494 self._disconnect_adapter(ob)
495 if not len(model):
496 return True
497 return False
499 def _disconnect_adapter(self, adapter):
500 adapter.disconnect()
501 adapter.signal_disconnect(Event.ItemsAddedSignal,
502 self._adapter_updated_handler)
503 adapter.signal_disconnect(Event.FeedPolledSignal,
504 self._adapter_updated_handler)
505 adapter.signal_disconnect(Event.FeedStatusChangedSignal,
506 self._adapter_updated_handler)
507 del adapter
509 def _adapter_updated_handler(self, signal, feed_index):
510 self._update_adapter_view(signal.sender, feed_index)
512 def _update_adapter_view(self, adapter, feed_index):
513 new = adapter.unread
514 row = self._model[feed_index]
515 row[Column.NAME] = adapter.title
516 row[Column.UNREAD] = new[0]
517 row[Column.BOLD] = (pango.WEIGHT_NORMAL, pango.WEIGHT_BOLD)[new[1] > 0]
518 row[Column.OBJECT] = adapter
519 status, pixbuf = adapter.status_icon
520 row[Column.STATUS_FLAG] = status
521 if pixbuf:
522 row[Column.STATUS_PIXBUF] = pixbuf
523 self._view.queue_draw()
525 def _feeds_changed(self, signal):
526 self._feed_view_update(signal.feed)
528 def _feed_detail_changed(self, signal):
529 self._feed_view_update(signal.sender)
531 def _feed_item_read(self, signal):
532 path = (0,)
533 selection = self._view.get_selection()
534 selected_row = selection.get_selected()
535 if selected_row:
536 model, treeiter = selected_row
537 path = model.get_path(treeiter)
538 treerow = self._model[path]
539 adapter = treerow[Column.OBJECT]
540 self._update_adapter_view(adapter, path)
542 def _feed_all_items_read(self, signal):
543 pass
545 def _feed_view_update(self, feed):
546 for index, f in enumerate(self._model):
547 adapter = self._model[index][Column.OBJECT]
548 if adapter.feed is feed:
549 self._update_adapter_view(adapter, index)
550 break
551 return
553 def _feeds_sorted_cb(self, signal):
554 self.model.set_sort_func(Column.NAME, self._sort_func)
555 if not signal.descending:
556 self.model.set_sort_column_id(Column.NAME, gtk.SORT_ASCENDING)
557 else:
558 self.model.set_sort_column_id(Column.NAME, gtk.SORT_DESCENDING)
559 self.model.sort_column_changed()
560 return
562 def _fcategory_changed_cb(self,signal):
563 if signal.sender is self._curr_category:
564 self._curr_category = signal.sender
565 self.display_category_feeds(self._curr_category)
566 return
568 def expand_row(self, obj):
569 obj.open = True
571 def collapse_row(self, obj):
572 obj.open = False
574 def move_feed(self, sidx, tidx):
575 self._curr_category.move_feed(sidx, tidx)
576 return
578 class DisplayAdapter(object, Event.SignalEmitter):
580 View adapter for feeds and categories
582 def __init__(self, ob):
583 self._ob = ob
584 Event.SignalEmitter.__init__(self)
585 self.initialize_slots(Event.ItemReadSignal,
586 Event.ItemsAddedSignal,
587 Event.AllItemsReadSignal,
588 Event.FeedPolledSignal,
589 Event.FeedStatusChangedSignal)
591 def equals(self, ob):
592 return self._ob is ob
594 class FeedDisplayAdapter(DisplayAdapter):
595 """Adapter for displaying Feed objects in the tree"""
596 def __init__(self, ob):
597 DisplayAdapter.__init__(self, ob)
598 ob.signal_connect(Event.ItemReadSignal, self.resend_signal)
599 ob.signal_connect(Event.ItemsAddedSignal, self.resend_signal)
600 ob.signal_connect(Event.AllItemsReadSignal, self.resend_signal)
601 ob.signal_connect(Event.FeedPolledSignal, self.resend_signal)
602 ob.signal_connect(Event.FeedStatusChangedSignal, self.resend_signal)
604 def disconnect(self):
605 self._ob.signal_disconnect(
606 Event.ItemReadSignal, self.resend_signal)
607 self._ob.signal_disconnect(
608 Event.ItemsAddedSignal, self.resend_signal)
609 self._ob.signal_disconnect(
610 Event.AllItemsReadSignal, self.resend_signal)
611 self._ob.signal_disconnect(
612 Event.FeedPolledSignal, self.resend_signal)
613 self._ob.signal_disconnect(
614 Event.FeedStatusChangedSignal, self.resend_signal)
616 def resend_signal(self, signal):
617 new = copy.copy(signal)
618 new.sender = self
619 self.emit_signal(new)
621 @property
622 def title(self):
623 return self._ob.title
625 @property
626 def unread(self):
627 nu = self._ob.n_items_unread
628 if nu != 0:
629 return ("%s" % nu, nu)
630 else:
631 return ("", nu)
633 @property
634 def status_icon(self):
635 if self._ob.process_status is not Feed.Feed.STATUS_IDLE:
636 return (1, gtk.STOCK_EXECUTE)
637 elif self._ob.error:
638 return (1, gtk.STOCK_DIALOG_ERROR)
639 return (0, None)
641 @property
642 def feed(self):
643 return self._ob
645 contents = property(lambda x: None)
646 open = property(lambda x: None)
648 def create_adapter(ob):
649 if isinstance(ob, Feed.Feed):
650 return FeedDisplayAdapter(ob)