=?utf-8?q?Bug=20507683=20=E2=80=93=20Store=20application=20UI=20state
[straw.git] / straw / FeedListView.py
blob3b04c2af6575be7f438242996cd3acebc2f37484
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 from Constants import *
21 from error import debug
22 from model import Feed, Item, Category
23 import Config
24 import FeedManager
25 import MVP
26 import categoryproperties
27 import feedproperties
28 import gobject
29 import gtk
30 import helpers
31 import os, copy, locale, logging
33 class Column:
34 pixbuf, name, foreground, unread, object, editable = range(6)
36 _tmp_widget = gtk.Label()
38 class TreeViewNode(object):
39 def __init__(self, node, store):
40 self.node = node
41 self.store = store
43 self.node.connect("notify", self._on_unread_count_changed)
45 def _on_unread_count_changed(self, node, delta):
46 self.refresh()
48 def refresh(self):
49 iter = self.iter
51 if not iter:
52 return
54 self.store.set(iter, Column.pixbuf, self.pixbuf)
55 self.store.set(iter, Column.unread, self.node.unread_count)
56 self.store.set(iter, Column.name, self.title)
58 @property
59 def title(self):
60 ''' The title of the node be it a category or a feed '''
62 if self.node.type == "C":
63 title = self.node.name
64 elif self.node.type == "F":
65 title = self.node.title
67 if hasattr(self.node, "status") and (self.node.status & FS_UPDATING):
68 if title:
69 title = "<i>" + title + "</i>"
71 return title
73 @property
74 def unread_count(self):
75 ''' The title of the node be it a category or a feed '''
76 return self.node.unread_count
78 @property
79 def pixbuf(self):
80 ''' gets the pixbuf to display according to the status of the feed '''
81 global _tmp_widget
83 if isinstance(self.node, Feed):
84 if self.node.status == FS_ERROR:
85 return _tmp_widget.render_icon(gtk.STOCK_CANCEL, gtk.ICON_SIZE_MENU)
86 else:
87 return _tmp_widget.render_icon(gtk.STOCK_FILE, gtk.ICON_SIZE_MENU)
88 else:
89 return _tmp_widget.render_icon(gtk.STOCK_DIRECTORY, gtk.ICON_SIZE_MENU)
91 @property
92 def path_list(self):
93 path = []
94 node = copy.copy(self.node)
96 while node:
97 path.append(node.norder)
98 node = copy.copy(node.parent)
100 path.pop() # We don't need path to "root" category here since it's not in the tree view.
101 path.reverse()
103 return path
105 @property
106 def path(self):
107 path = self.path_list
109 if len(path) == 0:
110 return None
111 else:
112 return ":".join(map(str, path))
114 @property
115 def iter(self):
116 path = self.path
118 if path == None:
119 return None
120 else:
121 try:
122 return self.store.get_iter_from_string(path)
123 except:
124 return None
126 @property
127 def parent_path(self):
128 path = self.path_list
129 path.pop()
131 if len(path) == 0:
132 return None
133 else:
134 return ":".join(map(str, path))
136 @property
137 def parent_iter(self):
138 path = self.parent_path
140 if path == None:
141 return None
142 else:
143 return self.store.get_iter_from_string(path)
145 class FeedListModel:
146 ''' The model for the feed list view '''
148 def __init__(self):
149 self.refresh_tree()
150 self._init_signals()
152 def refresh_tree(self):
153 self.appmodel = FeedManager.get_model()
155 self._prepare_store()
156 self._prepare_model()
158 self._populate_tree(1, None, [])
160 def _init_signals(self):
161 FeedManager._get_instance().connect("feed-added", self._on_node_added)
162 FeedManager._get_instance().connect("feed-status-changed", self._on_feed_status_changed)
163 FeedManager._get_instance().connect("category-added", self._on_node_added)
165 def _prepare_model(self):
166 self.tv_nodes = dict([(node.id, TreeViewNode(node, self.store)) for node in self.appmodel.values()])
168 def _prepare_store(self):
169 self.store = gtk.TreeStore(gtk.gdk.Pixbuf, str, str, str, gobject.TYPE_PYOBJECT, bool)
171 def _populate_tree(self, parent_id, parent_iter, done):
172 for node in self.tv_nodes[parent_id].node.children:
173 tv_node = self.tv_nodes[node.id]
175 if node.type == "F":
176 self._create_row(tv_node)
177 tv_node.store = self.store
178 elif node.type == "C":
179 current_parent = self._create_row(tv_node)
180 tv_node.store = self.store
182 if self.tv_nodes.has_key(node.id):
183 self._populate_tree(node.id, current_parent, done)
185 def _create_row(self, node, editable = False):
186 return self.store.append(node.parent_iter, [node.pixbuf,
187 helpers.pango_escape(node.title),
188 'black',
189 node.unread_count,
190 node, editable])
192 def _on_node_added(self, src, node):
193 tv_node = TreeViewNode(node, self.store)
194 self.add_node(tv_node)
195 self._create_row(tv_node)
197 def _on_feed_status_changed(self, src, feed):
198 self.tv_nodes[feed.id].refresh()
200 def add_node(self, tv_node):
201 self.tv_nodes[tv_node.node.id] = tv_node
203 @property
204 def model(self):
205 return self.store
207 def search(self, rows, func, data):
208 if not rows: return None
209 for row in rows:
210 if func(row, data):
211 return row
212 result = self.search(row.iterchildren(), func, data)
213 if result: return result
214 return None
216 class FeedsView(MVP.WidgetView):
217 def _initialize(self):
218 self._widget.set_search_column(Column.name)
220 # pixbuf column
221 column = gtk.TreeViewColumn()
222 unread_renderer = gtk.CellRendererText()
223 column.pack_start(unread_renderer, False)
224 column.set_attributes(unread_renderer, text = Column.unread)
226 status_renderer = gtk.CellRendererPixbuf()
227 column.pack_start(status_renderer, False)
228 column.set_attributes(status_renderer, pixbuf = Column.pixbuf)
230 # feed title renderer
231 title_renderer = gtk.CellRendererText()
233 title_renderer.connect("edited", self.on_node_edit_title_edited)
234 title_renderer.connect("editing-canceled", self.on_node_edit_title_canceled)
236 #title_renderer.set_property('editable', True)
237 column.pack_start(title_renderer, False)
238 column.set_attributes(title_renderer,
239 foreground=Column.foreground,
240 markup=Column.name,
241 editable=Column.editable) #, weight=Column.BOLD)
243 self._widget.append_column(column)
245 selection = self._widget.get_selection()
246 selection.set_mode(gtk.SELECTION_MULTIPLE)
248 self._widget.connect("button_press_event", self._on_button_press_event)
249 self._widget.connect("popup-menu", self._on_popup_menu)
251 uifactory = helpers.UIFactory('FeedListActions')
252 action = uifactory.get_action('/feedlist_popup/refresh')
253 action.connect('activate', self.on_menu_poll_selected_activate)
254 action = uifactory.get_action('/feedlist_popup/add_child')
255 action.connect('activate', self.on_menu_add_child_activate)
256 action = uifactory.get_action('/feedlist_popup/mark_as_read')
257 action.connect('activate', self.on_menu_mark_all_as_read_activate)
258 action = uifactory.get_action('/feedlist_popup/stop_refresh')
259 action.connect('activate', self.on_menu_stop_poll_selected_activate)
260 action = uifactory.get_action('/feedlist_popup/remove')
261 action.connect('activate', self.on_remove_selected_feed)
262 action = uifactory.get_action('/feedlist_popup/properties')
263 action.connect('activate', self.on_display_properties_feed)
264 self.popup = uifactory.get_popup('/feedlist_popup')
266 treeview = self._widget
268 treeview.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, [("drop_yes", 0, 0)], gtk.gdk.ACTION_MOVE)
269 treeview.enable_model_drag_dest([("drop_yes", 0, 0)], gtk.gdk.ACTION_MOVE)
270 treeview.connect("drag_data_received", self._on_dragdata_received)
271 treeview.connect("drag_motion", self._on_drag_motion)
273 def _on_drag_motion(self, treeview, drag_context, x, y, eventtime):
274 temp = treeview.get_dest_row_at_pos(x, y)
276 if not temp:
277 return
279 model = treeview.get_model()
280 drop_path, drop_position = temp
282 # FIXME: Here we use only first selected node of possibly multiple
283 # selected. See fixme comment in self._on_dragdata_received.
284 source_node = [tv_node for tv_node in self.selected()][0]
286 drop_node = model[drop_path][Column.object]
287 source_path = source_node.path_list
289 sane_drop_path = self._check_drop_path(model, source_path, drop_path)
291 is_drop_into = drop_position == gtk.TREE_VIEW_DROP_INTO_OR_BEFORE or \
292 drop_position == gtk.TREE_VIEW_DROP_INTO_OR_AFTER
294 can_drop_into = drop_node.node.is_parent()
296 if sane_drop_path and ((is_drop_into and can_drop_into) or not is_drop_into):
297 treeview.enable_model_drag_dest([("drop_yes", 0, 0)], gtk.gdk.ACTION_MOVE)
298 else:
299 treeview.enable_model_drag_dest([("drop_no", 0, 0)], gtk.gdk.ACTION_MOVE)
301 def _on_dragdata_received(self, treeview, drag_context, x, y, selection, info, eventtime):
302 model, pathlist = treeview.get_selection().get_selected_rows()
304 if len(pathlist) > 1:
305 # FIXME: Maybe we want to support drag and drop for multiple rows?
306 # For now it's not worth it while there are other things to do.
307 drag_context.finish(False, False, eventtime)
308 return
310 source_path = pathlist[0]
311 source_iter = model.get_iter(source_path)
313 temp = treeview.get_dest_row_at_pos(x, y)
315 if temp != None:
316 drop_path, drop_pos = temp
317 else:
318 drop_path, drop_pos = (len(model) - 1,), gtk.TREE_VIEW_DROP_AFTER
320 effective_drop_path = self._calculate_effective_drop_path(source_path, drop_path, drop_pos)
322 if source_path == effective_drop_path:
323 drag_context.finish(False, False, eventtime)
324 return
326 drop_iter = model.get_iter(drop_path)
328 if not self._check_drop_path(model, source_path, drop_path):
329 drag_context.finish(False, False, eventtime)
330 return
332 node = model[source_path][Column.object].node
333 self._iter_copy(model, source_iter, drop_iter, drop_pos)
335 drag_context.finish(True, True, eventtime)
336 FeedManager.move_node(node, effective_drop_path)
338 def _check_drop_path(self, model, source_path, drop_path):
340 Verifies if a drop path is not within the subtree of source path so that
341 we can disallow dropping parent into its own subtree etc. using this check.
344 return list(drop_path[0:len(source_path)]) != list(source_path)
346 def _iter_copy(self, model, iter_to_copy, target_iter, pos):
348 Recursively copies GTK TreeView iters from source to target using
349 GTK relative data about drag and drop operation.
352 path = model.get_path(iter_to_copy)
354 if (pos == gtk.TREE_VIEW_DROP_INTO_OR_BEFORE) or (pos == gtk.TREE_VIEW_DROP_INTO_OR_AFTER):
355 new_iter = model.prepend(target_iter, model[path])
356 elif pos == gtk.TREE_VIEW_DROP_BEFORE:
357 new_iter = model.insert_before(None, target_iter, model[path])
358 elif pos == gtk.TREE_VIEW_DROP_AFTER:
359 new_iter = model.insert_after(None, target_iter, model[path])
361 n = model.iter_n_children(iter_to_copy)
363 for i in range(n):
364 next_iter_to_copy = model.iter_nth_child(iter_to_copy, n - i - 1)
365 self._iter_copy(model, next_iter_to_copy, new_iter, gtk.TREE_VIEW_DROP_INTO_OR_BEFORE)
367 def _calculate_effective_drop_path(self, source_path, drop_path, drop_pos):
369 Calculate effective absolute drop path given drop_pos and source/destination
370 of drag and drop operation. GTK uses relative terms for describing drop
371 destination (after/before/into etc.) while we prefer absolute drop path
372 and we can take care of reordering ourselves.
375 result = list(drop_path)
376 same_level = len(source_path) == len(drop_path) and source_path[:-1] == drop_path[:-1]
378 if drop_pos == gtk.TREE_VIEW_DROP_INTO_OR_BEFORE:
379 result.append(0)
380 elif drop_pos == gtk.TREE_VIEW_DROP_INTO_OR_AFTER:
381 result.append(0)
382 elif drop_pos == gtk.TREE_VIEW_DROP_BEFORE:
383 if not same_level or (same_level and source_path[-1] < drop_path[-1]):
384 if result[-1] > 0:
385 result[-1] -= 1
386 elif drop_pos == gtk.TREE_VIEW_DROP_AFTER:
387 if not same_level or (same_level and source_path[-1] > drop_path[-1]):
388 result[-1] += 1
390 return tuple(result)
392 def _model_set(self):
393 self._widget.set_model(self._model.model)
395 def add_selection_changed_listener(self, listener):
396 selection = self._widget.get_selection()
397 selection.connect('changed', listener.feedlist_selection_changed, Column.object)
399 def _on_popup_menu(self, treeview, *args):
400 self.popup.popup(None, None, None, 0, 0)
402 def _on_button_press_event(self, treeview, event):
403 retval = 0
405 if event.button == 3:
406 x = int(event.x)
407 y = int(event.y)
408 time = gtk.get_current_event_time()
409 path = treeview.get_path_at_pos(x, y)
411 if path is None:
412 return 1
414 path = path[0]
415 self.node_at_popup = self.model.store[path][Column.object]
416 treeview.grab_focus()
418 if self.selected_count() < 2:
419 selection = treeview.get_selection()
420 selection.unselect_all()
421 selection.select_path(path)
423 self.popup.popup(None, None, None, event.button, time)
424 retval = 1
426 return retval
428 def get_selected_node(self):
429 nodes = [node for node in self.selected()]
431 if len(nodes) > 0:
432 return nodes[0].node
433 else:
434 return None
436 def get_expanded_nodes(self):
437 expanded = []
439 def add(treeview, path, expanded):
440 node = treeview.get_model()[path][Column.object].node
441 expanded.append(node)
443 self._widget.map_expanded_rows(add, expanded)
445 return expanded
447 def expand_nodes(self, nodes):
448 for node_id in nodes:
449 if node_id in self.model.tv_nodes:
450 path = self.model.tv_nodes[node_id].path
451 self._widget.expand_row(path, False)
453 def select_node(self, id):
454 path = self.model.tv_nodes[id].path
456 if not path:
457 return
459 selection = self._widget.get_selection()
460 selection.unselect_all()
461 self._widget.expand_to_path(path)
462 selection.select_path(path)
463 self._widget.grab_focus()
465 def selected_count(self):
466 selection = self._widget.get_selection()
467 pathlist = selection.get_selected_rows()[1]
468 return len(pathlist)
470 def selected(self):
471 selection = self._widget.get_selection()
472 (model, pathlist) = selection.get_selected_rows()
473 nodes = [model[path][Column.object] for path in pathlist]
475 for tv_node in nodes:
476 yield tv_node
478 def foreach_selected(self, func):
479 selection = self._widget.get_selection()
480 (model, pathlist) = selection.get_selected_rows()
481 iters = [model.get_iter(path) for path in pathlist]
482 try:
483 for treeiter in iters:
484 object = model.get_value(treeiter, Column.object)
485 func(object, model, treeiter)
486 except TypeError, te:
487 logging.exception(te)
489 def on_menu_add_child_activate(self, *args):
490 self.begin_add_category(self.node_at_popup.node)
492 def begin_add_category(self, node):
493 category = Category()
494 category.parent = node
495 category.norder = len(node.children)
496 self.new_child = TreeViewNode(category, self.model.store)
497 iter = self.model._create_row(self.new_child, editable = True)
498 path = self.model.store.get_path(iter)
499 column = self._widget.get_column(0)
501 parent_path = self.new_child.parent_path
503 if parent_path:
504 self._widget.expand_row(parent_path, False)
506 self._widget.set_cursor_on_cell(path, focus_column = column, start_editing = True)
508 def on_menu_poll_selected_activate(self, *args):
509 """config = Config.get_instance()
511 if config.offline: #XXX
512 config.offline = not config.offline"""
514 FeedManager.update_nodes([node.node for node in self.selected()])
516 def on_menu_stop_poll_selected_activate(self, *args):
517 self.foreach_selected(lambda o,*args: o.feed.router.stop_polling())
519 def on_menu_mark_all_as_read_activate(self, *args):
520 self.foreach_selected(lambda o,*args: o.feed.mark_items_as_read())
522 def on_remove_selected_feed(self, *args):
523 nodes = [tv_node for tv_node in self.selected()]
525 FeedManager.delete_nodes([tv_node.node.id for tv_node in nodes])
527 for node in nodes:
528 iter = node.iter
530 if iter:
531 self.model.store.remove(iter)
533 def on_node_edit_title_canceled(self, cellrenderer):
534 if self.new_child:
535 debug("on_node_edit_title_canceled (new_child), removing %s" % str(self.new_child.path))
536 self.model.store.remove(self.new_child.iter)
537 self.new_child = None
539 def on_node_edit_title_edited(self, cellrenderertext, path, new_text):
540 if len(new_text) > 0:
541 self.new_child.node.name = new_text
542 FeedManager.save_category(self.new_child.node)
544 self.model.store.remove(self.new_child.iter)
545 self.new_child = None
547 def on_display_properties_feed(self, *args):
548 selected_tv_node = [tv_node for tv_node in self.selected()][0]
549 self._presenter.show_feed_information(selected_tv_node.node)
551 def add_category(self):
552 self.begin_add_category(self._model.tv_nodes[1].node)
554 def select_first_feed(self):
555 selection = self._widget.get_selection()
556 (model, pathlist) = selection.get_selected_rows()
557 treeiter = model.get_iter_first()
558 if not treeiter or not model.iter_is_valid(treeiter):
559 return False
560 self.set_cursor(treeiter)
561 return True
563 def select_next_feed(self, with_unread=False):
564 ''' Scrolls to the next feed in the feed list
566 If there is no selection, selects the first feed. If multiple feeds
567 are selected, selects the feed after the last selected feed.
569 If unread is True, selects the next unread with unread items.
571 If the selection next-to-be is a category, go to the iter its first
572 child. If current selection is a child, then go to (parent + 1),
573 provided that (parent + 1) is not a category.
575 has_unread = False
576 def next(model, current):
577 treeiter = model.iter_next(current)
578 if not treeiter: return False
579 if model.iter_depth(current): next(model, model.iter_parent(current))
580 path = model.get_path(treeiter)
581 if with_unread and model[path][Column.unread] < 1:
582 next(model, current)
583 self.set_cursor(treeiter)
584 return True
585 selection = self._widget.get_selection()
586 (model, pathlist) = selection.get_selected_rows()
587 iters = [model.get_iter(path) for path in pathlist]
588 try:
589 current = iters.pop()
590 if model.iter_has_child(current):
591 iterchild = model.iter_children(current)
592 # make the row visible
593 path = model.get_path(iterchild)
594 for i in range(len(path)):
595 self._widget.expand_row(path[:i+1], False)
596 # select his first born child
597 if with_unread and model[path][Column.unread] > 0:
598 self.set_cursor(iterchild)
599 has_unread = True
600 else:
601 has_unread = next(model, current)
602 has_unread = next(model,current)
603 except IndexError:
604 self.set_cursor(model.get_iter_first())
605 has_unread = True
606 return has_unread
608 def select_previous_feed(self):
609 ''' Scrolls to the previous feed in the feed list.
611 If there is no selection, selects the first feed. If there's multiple
612 selection, selects the feed before the first selected feed.
614 If the previous selection is a category, select the last node in that
615 category. If the current selection is a child, then go to (parent -
616 1). If parent is the first feed, wrap and select the last feed or
617 category in the list.
619 def previous(model, current):
620 path = model.get_path(current)
621 treerow = model[path[-1]-1]
622 self.set_cursor(treerow.iter)
623 selection = self._widget.get_selection()
624 (model, pathlist) = selection.get_selected_rows()
625 iters = [model.get_iter(path) for path in pathlist]
626 try:
627 current_first = iters.pop(0)
628 if model.iter_has_child(current_first):
629 children = model.iter_n_children(current_first)
630 treeiter = model.iter_nth_child(children - 1)
631 self.set_cursor(treeiter)
632 return
633 previous(model, current_first)
634 except IndexError:
635 self.set_cursor(model.get_iter_first())
636 return
638 def set_cursor(self, treeiter, col_id=None, edit=False):
639 if not treeiter:
640 return
642 column = None
643 path = self._model.model.get_path(treeiter)
645 if col_id:
646 column = self._widget.get_column(col_id)
648 self._widget.set_cursor(path, column, edit)
649 self._widget.scroll_to_cell(path, column)
650 self._widget.grab_focus()
652 class FeedsPresenter(MVP.BasicPresenter):
653 def _initialize(self):
654 self.model = FeedListModel()
656 def store_state(self):
657 node = self.view.get_selected_node()
658 id = -1
660 if node:
661 id = node.id
663 Config.set(OPTION_LAST_SELECTED_NODE, id)
664 Config.set(OPTION_LAST_EXPANDED_NODES, ",".join([str(node.id) for node in self.view.get_expanded_nodes()]))
666 def restore_state(self):
667 id = Config.get(OPTION_LAST_SELECTED_NODE)
669 if id != -1:
670 self.view.select_node(id)
672 expanded = Config.get(OPTION_LAST_EXPANDED_NODES)
674 if expanded:
675 try:
676 expanded = map(int, expanded.split(","))
677 except ValueError:
678 expanded = []
680 self.view.expand_nodes(expanded)
682 def add_category(self):
683 self.view.add_category()
685 def select_first_feed(self):
686 return self.view.select_first_feed()
688 def select_next_feed(self, with_unread=False):
689 return self.view.select_next_feed(with_unread)
691 def select_previous_feed(self):
692 return self.view.select_previous_feed()
694 def show_feed_information(self, node):
695 if node.type == "C":
696 properties = categoryproperties
697 elif node.type == "F":
698 properties = feedproperties
700 properties.show(node)