%C escape suspends colors as well as attributes
[canto-curses.git] / canto_curses / tag.py
index b005807..a636d08 100644 (file)
@@ -1,16 +1,17 @@
 # -*- coding: utf-8 -*-
 #Canto-curses - ncurses RSS reader
-#   Copyright (C) 2010 Jack Miller <jack@codezen.org>
+#   Copyright (C) 2014 Jack Miller <jack@codezen.org>
 #
 #   This program is free software; you can redistribute it and/or modify
 #   it under the terms of the GNU General Public License version 2 as 
 #   published by the Free Software Foundation.
 
-from canto_next.hooks import call_hook, on_hook, remove_hook
+from canto_next.hooks import call_hook, on_hook, unhook_all
+from canto_next.plugins import Plugin, PluginHandler
 from canto_next.rwlock import read_lock
 
-from .locks import sync_lock
-from .parser import parse_conditionals, eval_theme_string, prep_for_display
+from .locks import sync_lock, config_lock
+from .parser import try_parse, try_eval, prep_for_display
 from .theme import FakePad, WrapPad, theme_print, theme_reset, theme_border
 from .config import config, DEFAULT_TAG_FSTRING
 from .story import Story
@@ -23,15 +24,24 @@ log = logging.getLogger("TAG")
 
 # TagCore provides the core tag functionality of keeping track of a list of IDs.
 
-# The Tag class manages stories. Externally, it looks
-# like a Tag takes IDs from the backend and renders an ncurses pad. No class
-# other than Tag actually touches Story objects directly.
+# The Tag class manages stories. Externally, it looks like a Tag takes IDs from
+# the backend and renders an ncurses pad. No class other than Tag actually
+# touches Story objects directly.
 
-class Tag(list):
+class TagPlugin(Plugin):
+    pass
+
+alltags = []
+
+class Tag(PluginHandler, list):
     def __init__(self, tagcore, callbacks):
         list.__init__(self)
+        PluginHandler.__init__(self)
+
         self.tagcore = tagcore
         self.tag = tagcore.tag
+        self.is_tag = True
+        self.updates_pending = 0
 
         self.pad = None
         self.footpad = None
@@ -66,37 +76,52 @@ class Tag(list):
         self.marked = False
 
         # Information from last refresh
-        self.lines = 0
         self.footlines = 0
         self.extra_lines = 0
         self.width = 0
 
+        self.collapsed = False
+        self.border = False
+        self.enumerated = False
+        self.abs_enumerated = False
+
+        # Formats for plugins to override
+        self.pre_format = ""
+        self.post_format = ""
+
         # Global indices (for enumeration)
-        self.item_offset = None
-        self.visible_tag_offset = None
-        self.tag_offset = None
-        self.sel_offset = None
+        self.item_offset = 0
+        self.visible_tag_offset = 0
+        self.tag_offset = 0
+        self.sel_offset = 0
 
-        on_hook("curses_opt_change", self.on_opt_change)
-        on_hook("curses_tag_opt_change", self.on_tag_opt_change)
-        on_hook("curses_attributes", self.on_attributes)
+        on_hook("curses_opt_change", self.on_opt_change, self)
+        on_hook("curses_tag_opt_change", self.on_tag_opt_change, self)
+        on_hook("curses_attributes", self.on_attributes, self)
+        on_hook("curses_items_added", self.on_items_added, self)
 
         # Upon creation, this Tag adds itself to the
         # list of all tags.
 
-        # XXX: FUCKING LOCK IT
-        callbacks["get_var"]("alltags").append(self)
-        config.eval_tags()
+        alltags.append(self)
 
         self.sync(True)
 
+        self.plugin_class = TagPlugin
+        self.update_plugin_lookups()
+
     def die(self):
+        log.debug("tag %s die()" % self.tag)
         # Reset so items get die() called and everything
         # else is notified about items disappearing.
 
-        remove_hook("curses_opt_change", self.on_opt_change)
-        remove_hook("curses_tag_opt_change", self.on_tag_opt_change)
-        remove_hook("curses_attributes", self.on_attributes)
+        for s in self:
+            s.die()
+        del self[:]
+
+        alltags.remove(self)
+
+        unhook_all(self)
 
     def on_item_state_change(self, item):
         self.need_redraw()
@@ -108,7 +133,7 @@ class Tag(list):
                 "border" in opts["taglist"]):
             self.need_redraw()
 
-        if "tag" in opts:
+        if "tagobj" in opts:
             self.need_redraw()
 
     def on_tag_opt_change(self, opts):
@@ -129,63 +154,33 @@ class Tag(list):
                 self.need_redraw()
                 break
 
+    def on_items_added(self, tagcore, added):
+        cur_ids = self.get_ids()
+        if tagcore == self.tagcore:
+            for story_id in added:
+                if story_id not in cur_ids:
+                    self.updates_pending += 1
+            self.need_redraw()
+
     # We override eq so that empty tags don't evaluate
     # as equal and screw up things like enumeration.
 
     def __eq__(self, other):
-        if not hasattr(other, "tag") or self.tag != other.tag:
+        if other and (not other.is_tag or self.tag != other.tag):
             return False
         return list.__eq__(self, other)
 
     def __str__(self):
-        return "tag: %s" % self.tag
+        return "%s" % self.tag[self.tag.index(':') + 1:]
 
     def get_id(self, id):
         for item in self:
             if item.id == id:
                 return item
-        return None
 
     def get_ids(self):
         return [ s.id for s in self ]
 
-    # Take a list of ordered ids and reorder ourselves, without generating any
-    # unnecessary add/remove hooks.
-
-    def reorder(self, ids):
-        cur_stories = [ s for s in self ]
-
-        # Perform the actual reorder.
-        stories = [ self.get_id(id) for id in ids ]
-
-        del self[:]
-        list.extend(self, stories)
-
-        # Deal with items that aren't listed. Usually this happens if the item
-        # would be filtered, but is protected for some reason (like selection)
-
-        # NOTE: This is bad behavior, but if we don't retain these items, other
-        # code will crap-out expecting this item to exist. Built-in transforms
-        # are hardened to never discard items with the filter-immune reason,
-        # like selection, so this is just for bad user transforms.
-
-        for s in cur_stories:
-            if s not in self:
-                log.warn("Warning: A filter is filtering filter-immune items.")
-                log.warn("Compensating. This may cause items to jump unexpectedly.")
-                list.append(self, s)
-
-        log.debug("Self: %s" % [ s for s in self ])
-
-        # Handle updating story information.
-        for i, story in enumerate(self):
-            story.set_rel_offset(i)
-            story.set_offset(self.item_offset + i)
-            story.set_sel_offset(self.sel_offset + i)
-
-        # Request redraw to update item counts.
-        self.need_redraw()
-
     # Inform the tag of global index of it's first item.
     def set_item_offset(self, offset):
         if self.item_offset != offset:
@@ -238,36 +233,18 @@ class Tag(list):
         self.changed = True
         self.callbacks["set_var"]("needs_redraw", True)
 
-    def do_changes(self, width):
-        if width != self.width or self.changed:
-            self.refresh(width)
-
-    def refresh(self, width):
-        self.width = width
-
-        lines = self.render_header(width, FakePad(width))
-
-        self.pad = curses.newpad(lines, width)
-        self.render_header(width, WrapPad(self.pad))
-
-        self.lines = lines
-
-        lines = self.render_footer(width, FakePad(width))
-
-        if lines:
-            self.footpad = curses.newpad(lines, width)
-            self.render_footer(width, WrapPad(self.footpad))
-        else:
-            self.footpad = None
+    def lines(self, width):
+        if width == self.width and not self.changed:
+            return self.lns
 
-        self.footlines = lines
-
-        self.changed = False
-
-    def render_header(self, width, pad):
-        tag_conf = self.callbacks["get_opt"]("tag")
+        tag_conf = self.callbacks["get_opt"]("tagobj")
         taglist_conf = self.callbacks["get_opt"]("taglist")
-        collapsed = self.callbacks["get_tag_opt"]("collapsed")
+
+        # Values to pass on to render
+        self.collapsed = self.callbacks["get_tag_opt"]("collapsed")
+        self.border = taglist_conf["border"]
+        self.enumerated = taglist_conf["tags_enumerated"]
+        self.abs_enumerated = taglist_conf["tags_enumerated_absolute"]
 
         # Make sure to strip out the category from category:name
         tag = self.tag.split(':', 1)[1]
@@ -289,26 +266,20 @@ class Tag(list):
         for attr in [ "selected", "unselected", "selected_end", "unselected_end" ]:
             passthru[attr] = tag_conf[attr]
 
-        fstring = tag_conf["format"]
-        try:
-            parsed = parse_conditionals(fstring)
-        except Exception as e:
-            log.warn("Failed to parse conditionals in fstring: %s" %
-                    fstring)
-            log.warn("\n" + "".join(traceback.format_exc()))
-            log.warn("Falling back to default.")
-            parsed = parse_conditionals(DEFAULT_TAG_FSTRING)
-
-        values = { 'en' : taglist_conf["tags_enumerated"],
-                    'aen' : taglist_conf["tags_enumerated_absolute"],
-                    'c' : collapsed,
+        passthru['pre'] = self.pre_format
+        passthru['post'] = self.post_format
+
+        parsed = try_parse(tag_conf["format"], DEFAULT_TAG_FSTRING)
+        parsed_pre = try_parse(self.pre_format, "")
+        parsed_post = try_parse(self.post_format, "")
+
+        values = {  'c' : self.collapsed,
                     't' : tag,
                     'sel' : self.selected,
                     'n' : unread,
-                    'to' : self.tag_offset,
-                    'vto' : self.visible_tag_offset,
                     "extra_tags" : extra_tags,
                     'tag' : self,
+                    'pending' : self.updates_pending,
                     'prep' : prep_for_display}
 
         # Prep all text values for display.
@@ -319,38 +290,72 @@ class Tag(list):
 
         values.update(passthru)
 
-        try:
-            s = eval_theme_string(parsed, values)
-        except Exception as e:
-            log.warn("Failed to evaluate fstring: %s" % fstring)
-            log.warn("\n" + "".join(traceback.format_exc()))
-            log.warn("Falling back to default")
+        values["pre"] = try_eval(parsed_pre, values, "")
+        values["post"] = try_eval(parsed_post, values, "")
+        self.evald_string = try_eval(parsed, values, DEFAULT_TAG_FSTRING)
+
+        self.pad = None
+        self.footpad = None
+        self.width = width
+        self.changed = False
 
-            parsed = parse_conditionals(DEFAULT_TAG_FSTRING)
-            s = eval_theme_string(parsed, values)
+        self.lns = self.render_header(width, FakePad(width))
+        self.footlines = self.render_footer(width, FakePad(width))
 
-        lines = 0
+        return self.lns
+
+    def pads(self, width):
+        if self.pad and (self.footpad or not self.footlines) and not self.changed:
+            return self.lns
+
+        self.pad = curses.newpad(self.lines(width), width)
+        self.render_header(width, WrapPad(self.pad))
+
+        if self.footlines:
+            self.footpad = curses.newpad(self.footlines, width)
+            self.render_footer(width, WrapPad(self.footpad))
+        return self.lns
 
-        while s:
-            s = theme_print(pad, s, width, "", "")
-            lines += 1
+    def render_header(self, width, pad):
+        s = self.evald_string
+        lines = 0
 
-        if not collapsed and taglist_conf["border"]:
-            theme_print(pad, theme_border("ts") * (width - 2), width,\
-                    "%B%1"+ theme_border("tl"), theme_border("tr") + "%0%b")
-            lines += 1
+        try:
+            while s:
+                s = theme_print(pad, s, width, "", "")
+
+                if lines == 0:
+                    header = ""
+                    if self.enumerated:
+                        header += "%1[" + str(self.visible_tag_offset) + "]%0"
+                    if self.abs_enumerated:
+                        header += "%1[" + str(self.tag_offset) + "]%0"
+                    if header:
+                        pad.move(0, 0)
+                        theme_print(pad, header, width, "", "", False, False)
+                        try:
+                            pad.move(1, 0)
+                        except:
+                            pass
+                lines += 1
+
+            if not self.collapsed and self.border:
+                theme_print(pad, theme_border("ts") * (width - 2), width,\
+                        "%B"+ theme_border("tl"), theme_border("tr") + "%b")
+                lines += 1
+        except Exception as e:
+            tb = traceback.format_exc()
+            log.debug("Tag exception:")
+            log.debug("\n" + "".join(tb))
 
         theme_reset()
 
         return lines
 
     def render_footer(self, width, pad):
-        taglist_conf = self.callbacks["get_opt"]("taglist")
-        collapsed = self.callbacks["get_tag_opt"]("collapsed")
-
-        if not collapsed and taglist_conf["border"]:
+        if not self.collapsed and self.border:
             theme_print(pad, theme_border("bs") * (width - 2), width,\
-                    "%B%1" + theme_border("bl"), theme_border("br") + "%0%b")
+                    "%B" + theme_border("bl"), theme_border("br") + "%b")
             theme_reset()
             return 1
         return 0
@@ -358,36 +363,62 @@ class Tag(list):
     # Synchronize this Tag with its TagCore
 
     def sync(self, force=False):
-        if force or self.tagcore.changed:
-            my_ids = [ s.id for s in self ]
+        if force or self.tagcore.changes:
             current_stories = []
             added_stories = []
 
+            sel = self.callbacks["get_var"]("selected")
+
             self.tagcore.lock.acquire_read()
 
-            for id in self.tagcore:
-                if id in my_ids:
-                    s = self[my_ids.index(id)]
-                    current_stories.append(s)
-                    self.remove(s)
-                    my_ids.remove(s.id)
-                else:
+            self.tagcore.ack_changes()
+
+            for story in self:
+                if story.id in self.tagcore:
+                    current_stories.append((self.tagcore.index(story.id), story))
+                elif story == sel:
+
+                    # If we preserve the selection in an "undead" state, then
+                    # we keep set tagcore changed so that the next sync operation
+                    # will re-evaluate it.
+
+                    self.tagcore.changed()
+
+                    if current_stories:
+                        place = max([ x[0] for x in current_stories ]) + .5
+                    else:
+                        place = -1
+                    current_stories.append((place, story))
+
+            for place, id in enumerate(self.tagcore):
+                if id not in [ x[1].id for x in current_stories ]:
                     s = Story(self, id, self.callbacks)
-                    current_stories.append(s)
+                    current_stories.append((place, s))
                     added_stories.append(s)
 
             self.tagcore.lock.release_read()
 
             call_hook("curses_stories_added", [ self, added_stories ])
 
+            conf = config.get_conf()
+            if conf["update"]["style"] == "maintain" or self.tagcore.was_reset:
+                self.tagcore.was_reset = False
+                current_stories.sort()
+
+            current_stories = [ x[1] for x in current_stories ]
+
+            deleted = []
+
+            for story in self:
+                if not story in current_stories:
+                    deleted.append(story)
+                    story.die()
+
             # Properly dispose of the remaining stories
 
-            call_hook("curses_stories_removed", [ self, self[:] ])
+            call_hook("curses_stories_removed", [ self, deleted ])
 
-            for s in self:
-                s.die()
             del self[:]
-
             self.extend(current_stories)
 
             # Trigger a refresh so that classes above (i.e. TagList) will remap
@@ -398,3 +429,5 @@ class Tag(list):
         # Pass the sync onto story objects
         for s in self:
             s.sync()
+
+        self.updates_pending = 0