1 # -*- coding: utf-8 -*-
2 #Canto-curses - ncurses RSS reader
3 # Copyright (C) 2010 Jack Miller <jack@codezen.org>
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License version 2 as
7 # published by the Free Software Foundation.
9 from canto_next.hooks import call_hook, on_hook, remove_hook
11 from parser import parse_conditionals, eval_theme_string
12 from theme import FakePad, WrapPad, theme_print
13 from story import Story
19 log = logging.getLogger("TAG")
21 # The Tag class manages stories. Externally, it looks
22 # like a Tag takes IDs from the backend and renders an ncurses pad. No class
23 # other than Tag actually touches Story objects directly.
25 DEFAULT_TAG_FSTRING = "%?{sel}(%R:)%?{c}([+]:[-])%?{en}([%{to}]:)%?{aen}([%{vto}]:) %t [%B%2%n%1%b]%?{sel}(%r:)"
28 def __init__(self, tag, callbacks):
33 # Note that Tag() is only given the top-level CantoCursesGui
34 # callbacks as it shouldn't be doing input / refreshing
37 self.callbacks = callbacks.copy()
39 # Modify our own callbacks so that *_tag_opt assumes
42 self.callbacks["get_tag_opt"] =\
43 lambda x : callbacks["get_tag_opt"](self, x)
44 self.callbacks["set_tag_opt"] =\
45 lambda x, y : callbacks["set_tag_opt"](self, x, y)
47 # This could be implemented as a generic, top-level hook but then N
48 # tags would have access to story objects they shouldn't have and
49 # would have to check every items membership in self, which would be
50 # pointless and time-consuming.
52 self.callbacks["item_state_change"] =\
53 self.on_item_state_change
55 # Are there changes pending?
61 # Information from last refresh
65 # Global indices (for enumeration)
67 self.visible_tag_offset = 0
71 on_hook("opt_change", self.on_opt_change)
72 on_hook("tag_opt_change", self.on_tag_opt_change)
74 # Upon creation, this Tag adds itself to the
77 callbacks["get_var"]("alltags").append(self)
80 # Reset so items get die() called and everything
81 # else is notified about items disappearing.
84 remove_hook("opt_change", self.on_opt_change)
85 remove_hook("tag_opt_change", self.on_tag_opt_change)
87 def on_item_state_change(self, item):
90 def on_opt_change(self, opts):
91 if "taglist.tags_enumerated" in opts or \
92 "taglist.tags_enumerated_absolute" in opts:
95 def on_tag_opt_change(self, tag, opts):
99 # We override eq so that empty tags don't evaluate
100 # as equal and screw up things like enumeration.
102 def __eq__(self, other):
103 if not hasattr(other, "tag") or self.tag != other.tag:
105 return list.__eq__(self, other)
107 # Create Story from ID before appending to list.
109 def add_items(self, ids):
112 s = Story(id, self.callbacks)
117 s.set_rel_offset(rel)
118 s.set_offset(self.item_offset + rel)
119 s.set_sel_offset(self.sel_offset + rel)
121 call_hook("items_added", [ self, added ] )
123 # Take a list of ordered ids and reorder ourselves, without generating any
124 # unnecessary add/remove hooks.
126 def reorder(self, ids):
127 cur_stories = [ s for s in self ]
129 # Perform the actual reorder.
130 stories = [ self.get_id(id) for id in ids ]
133 list.extend(self, stories)
135 # Deal with items that aren't listed. Usually this happens if the item
136 # would be filtered, but is protected for some reason (like selection)
138 # NOTE: This is bad behavior, but if we don't retain these items, other
139 # code will crap-out expecting this item to exist. Built-in transforms
140 # are hardened to never discard items with the filter-immune reason,
141 # like selection, so this is just for bad user transforms.
143 for s in cur_stories:
145 log.warn("Warning: A filter is filtering filter-immune items.")
146 log.warn("Compensating. This may cause items to jump unexpectedly.")
149 log.debug("Self: %s" % [ s for s in self ])
151 # Handle updating story information.
152 for i, story in enumerate(self):
153 story.set_rel_offset(i)
154 story.set_offset(self.item_offset + i)
155 story.set_sel_offset(self.sel_offset + i)
157 # Remove Story based on ID
159 def remove_items(self, ids):
162 # Copy self so we can remove from self
163 # without screwing up iteration.
165 for idx, item in enumerate(self[:]):
167 log.debug("removing: %s" % (item.id,))
169 list.remove(self, item)
173 # Update indices of items.
174 for i, story in enumerate(self):
175 story.set_rel_offset(i)
176 story.set_offset(self.item_offset + i)
177 story.set_sel_offset(self.sel_offset + i)
179 call_hook("items_removed", [ self, removed ] )
181 # Remove all stories from this tag.
187 call_hook("items_removed", [ self, self[:] ])
190 def get_id(self, id):
197 return [ s.id for s in self ]
199 # Inform the tag of global index of it's first item.
200 def set_item_offset(self, offset):
201 if self.item_offset != offset:
202 self.item_offset = offset
203 for i, item in enumerate(self):
204 item.set_offset(offset + i)
206 # Note that this cannot be short-cut (i.e.
207 # copout if sel_offset is already equal)
208 # because it's possible that it's the same
209 # without the items having ever been updated.
211 # Alternatively, we could reset them in
212 # on_tag_opt_change, but since the sel
213 # offset does not cause a redraw, there's
216 def set_sel_offset(self, offset):
217 self.sel_offset = offset
219 if not self.callbacks["get_tag_opt"]("collapsed"):
220 for i, item in enumerate(self):
221 item.set_sel_offset(offset + i)
223 def set_visible_tag_offset(self, offset):
224 if self.visible_tag_offset != offset:
225 self.visible_tag_offset = offset
228 def set_tag_offset(self, offset):
229 if self.tag_offset != offset:
230 self.tag_offset = offset
234 if not self.selected:
240 self.selected = False
243 def need_redraw(self):
245 self.callbacks["set_var"]("needs_redraw", True)
247 def do_changes(self, width):
248 if width != self.width or self.changed:
251 def refresh(self, width):
254 lines = self.render_header(width, FakePad(width))
256 self.pad = curses.newpad(lines, width)
257 self.render_header(width, WrapPad(self.pad))
262 def render_header(self, width, pad):
263 fstring = self.callbacks["get_opt"]("tag.format")
264 enumerated = self.callbacks["get_opt"]("taglist.tags_enumerated")
265 enumerated_absolute =\
266 self.callbacks["get_opt"]("taglist.tags_enumerated_absolute")
267 collapsed = self.callbacks["get_tag_opt"]("collapsed")
269 # Make sure to strip out the category from category:name
270 tag = self.tag.split(':', 1)[1]
272 unread = len([s for s in self\
273 if "canto-state" not in s.content or\
274 "read" not in s.content["canto-state"]])
276 # These are escapes that are handled in the theme_print
277 # lower in the function and should remain present after
281 for c in "RrDdUuBbSs012345678":
282 passthru[c] = "%" + c
286 parsed = parse_conditionals(fstring)
288 log.warn("Failed to parse conditionals in fstring: %s" %
290 log.warn("\n" + "".join(traceback.format_exc(e)))
291 log.warn("Falling back to default.")
292 parsed = parse_conditionals(DEFAULT_TAG_FSTRING)
294 values = { 'en' : enumerated,
295 'aen' : enumerated_absolute,
298 'sel' : self.selected,
300 'to' : self.tag_offset,
301 'vto' : self.visible_tag_offset,
304 values.update(passthru)
307 s = eval_theme_string(parsed, values)
309 log.warn("Failed to evaluate fstring: %s" % fstring)
310 log.warn("\n" + "".join(traceback.format_exc(e)))
311 log.warn("Falling back to default")
313 parsed = parse_conditionals(DEFAULT_TAG_FSTRING)
314 s = eval_theme_string(parsed, values)
321 s = theme_print(pad, s, width, u"", u"")