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
10 from canto_next.rwlock import read_lock
12 from .locks import sync_lock
13 from .parser import parse_conditionals, eval_theme_string, prep_for_display
14 from .theme import FakePad, WrapPad, theme_print, theme_reset, theme_border
15 from .config import config, DEFAULT_TAG_FSTRING
16 from .story import Story
22 log = logging.getLogger("TAG")
24 # TagCore provides the core tag functionality of keeping track of a list of IDs.
26 # The Tag class manages stories. Externally, it looks
27 # like a Tag takes IDs from the backend and renders an ncurses pad. No class
28 # other than Tag actually touches Story objects directly.
31 def __init__(self, tagcore, callbacks):
33 self.tagcore = tagcore
34 self.tag = tagcore.tag
39 # Note that Tag() is only given the top-level CantoCursesGui
40 # callbacks as it shouldn't be doing input / refreshing
43 self.callbacks = callbacks.copy()
45 # Modify our own callbacks so that *_tag_opt assumes
48 self.callbacks["get_tag_opt"] =\
49 lambda x : callbacks["get_tag_opt"](self.tag, x)
50 self.callbacks["set_tag_opt"] =\
51 lambda x, y : callbacks["set_tag_opt"](self.tag, x, y)
52 self.callbacks["get_tag_name"] = lambda : self.tag
54 # This could be implemented as a generic, top-level hook but then N
55 # tags would have access to story objects they shouldn't have and
56 # would have to check every items membership in self, which would be
57 # pointless and time-consuming.
59 self.callbacks["item_state_change"] =\
60 self.on_item_state_change
62 # Are there changes pending?
68 # Information from last refresh
74 # Global indices (for enumeration)
75 self.item_offset = None
76 self.visible_tag_offset = None
77 self.tag_offset = None
78 self.sel_offset = None
80 on_hook("curses_opt_change", self.on_opt_change)
81 on_hook("curses_tag_opt_change", self.on_tag_opt_change)
82 on_hook("curses_attributes", self.on_attributes)
84 # Upon creation, this Tag adds itself to the
87 # XXX: FUCKING LOCK IT
88 callbacks["get_var"]("alltags").append(self)
94 # Reset so items get die() called and everything
95 # else is notified about items disappearing.
98 remove_hook("curses_opt_change", self.on_opt_change)
99 remove_hook("curses_tag_opt_change", self.on_tag_opt_change)
100 remove_hook("curses_attributes", self.on_attributes)
102 def on_item_state_change(self, item):
105 def on_opt_change(self, opts):
106 if "taglist" in opts and\
107 ("tags_enumerated" in opts["taglist"] or\
108 "tags_enumerated_absolute" in opts["taglist"] or\
109 "border" in opts["taglist"]):
115 def on_tag_opt_change(self, opts):
116 if self.tag in list(opts.keys()):
118 if "collapsed" in tc:
123 def on_attributes(self, attributes):
125 if s.id in attributes:
129 # We override eq so that empty tags don't evaluate
130 # as equal and screw up things like enumeration.
132 def __eq__(self, other):
133 if not hasattr(other, "tag") or self.tag != other.tag:
135 return list.__eq__(self, other)
138 return "tag: %s" % self.tag
140 def get_id(self, id):
147 return [ s.id for s in self ]
149 # Take a list of ordered ids and reorder ourselves, without generating any
150 # unnecessary add/remove hooks.
152 def reorder(self, ids):
153 cur_stories = [ s for s in self ]
155 # Perform the actual reorder.
156 stories = [ self.get_id(id) for id in ids ]
159 list.extend(self, stories)
161 # Deal with items that aren't listed. Usually this happens if the item
162 # would be filtered, but is protected for some reason (like selection)
164 # NOTE: This is bad behavior, but if we don't retain these items, other
165 # code will crap-out expecting this item to exist. Built-in transforms
166 # are hardened to never discard items with the filter-immune reason,
167 # like selection, so this is just for bad user transforms.
169 for s in cur_stories:
171 log.warn("Warning: A filter is filtering filter-immune items.")
172 log.warn("Compensating. This may cause items to jump unexpectedly.")
175 log.debug("Self: %s" % [ s for s in self ])
177 # Handle updating story information.
178 for i, story in enumerate(self):
179 story.set_rel_offset(i)
180 story.set_offset(self.item_offset + i)
181 story.set_sel_offset(self.sel_offset + i)
183 # Request redraw to update item counts.
186 # Inform the tag of global index of it's first item.
187 def set_item_offset(self, offset):
188 if self.item_offset != offset:
189 self.item_offset = offset
190 for i, item in enumerate(self):
191 item.set_offset(offset + i)
193 # Note that this cannot be short-cut (i.e.
194 # copout if sel_offset is already equal)
195 # because it's possible that it's the same
196 # without the items having ever been updated.
198 # Alternatively, we could reset them in
199 # on_tag_opt_change, but since the sel
200 # offset does not cause a redraw, there's
203 def set_sel_offset(self, offset):
204 self.sel_offset = offset
206 if not self.callbacks["get_tag_opt"]("collapsed"):
207 for i, item in enumerate(self):
208 item.set_sel_offset(offset + i)
210 def set_visible_tag_offset(self, offset):
211 if self.visible_tag_offset != offset:
212 self.visible_tag_offset = offset
215 def set_tag_offset(self, offset):
216 if self.tag_offset != offset:
217 self.tag_offset = offset
221 if not self.selected:
227 self.selected = False
230 def need_refresh(self):
232 self.callbacks["set_var"]("needs_refresh", True)
234 def need_redraw(self):
236 self.callbacks["set_var"]("needs_redraw", True)
238 def do_changes(self, width):
239 if width != self.width or self.changed:
242 def refresh(self, width):
245 lines = self.render_header(width, FakePad(width))
247 self.pad = curses.newpad(lines, width)
248 self.render_header(width, WrapPad(self.pad))
252 lines = self.render_footer(width, FakePad(width))
255 self.footpad = curses.newpad(lines, width)
256 self.render_footer(width, WrapPad(self.footpad))
260 self.footlines = lines
264 def render_header(self, width, pad):
265 tag_conf = self.callbacks["get_opt"]("tag")
266 taglist_conf = self.callbacks["get_opt"]("taglist")
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 extra_tags = self.callbacks["get_tag_conf"](self.tag)['extra_tags']
278 # These are escapes that are handled in the theme_print
279 # lower in the function and should remain present after
283 for c in "RrDdUuBbSs012345678":
284 passthru[c] = "%" + c
286 for attr in [ "selected", "unselected", "selected_end", "unselected_end" ]:
287 passthru[attr] = tag_conf[attr]
289 fstring = tag_conf["format"]
291 parsed = parse_conditionals(fstring)
292 except Exception as e:
293 log.warn("Failed to parse conditionals in fstring: %s" %
295 log.warn("\n" + "".join(traceback.format_exc()))
296 log.warn("Falling back to default.")
297 parsed = parse_conditionals(DEFAULT_TAG_FSTRING)
299 values = { 'en' : taglist_conf["tags_enumerated"],
300 'aen' : taglist_conf["tags_enumerated_absolute"],
303 'sel' : self.selected,
305 'to' : self.tag_offset,
306 'vto' : self.visible_tag_offset,
307 "extra_tags" : extra_tags,
309 'prep' : prep_for_display}
311 # Prep all text values for display.
313 for value in list(values.keys()):
314 if type(values[value]) in [str, str]:
315 values[value] = prep_for_display(values[value])
317 values.update(passthru)
320 s = eval_theme_string(parsed, values)
321 except Exception as e:
322 log.warn("Failed to evaluate fstring: %s" % fstring)
323 log.warn("\n" + "".join(traceback.format_exc()))
324 log.warn("Falling back to default")
326 parsed = parse_conditionals(DEFAULT_TAG_FSTRING)
327 s = eval_theme_string(parsed, values)
332 s = theme_print(pad, s, width, "", "")
335 if not collapsed and taglist_conf["border"]:
336 theme_print(pad, theme_border("ts") * (width - 2), width,\
337 "%B%1"+ theme_border("tl"), theme_border("tr") + "%0%b")
344 def render_footer(self, width, pad):
345 taglist_conf = self.callbacks["get_opt"]("taglist")
346 collapsed = self.callbacks["get_tag_opt"]("collapsed")
348 if not collapsed and taglist_conf["border"]:
349 theme_print(pad, theme_border("bs") * (width - 2), width,\
350 "%B%1" + theme_border("bl"), theme_border("br") + "%0%b")
355 # Synchronize this Tag with its TagCore
357 @read_lock(sync_lock)
358 def sync(self, force=False):
359 if force or self.tagcore.changed:
360 my_ids = [ s.id for s in self ]
363 self.tagcore.lock.acquire_read()
365 for id in self.tagcore:
367 s = self[my_ids.index(id)]
368 new_stories.append(s)
372 new_stories.append(Story(id, self.callbacks))
374 self.tagcore.lock.release_read()
376 # Properly dispose of the remaining stories
381 self.extend(new_stories)
383 # Trigger a refresh so that classes above (i.e. TagList) will remap
388 # Pass the sync onto story objects