%C escape suspends colors as well as attributes
[canto-curses.git] / canto_curses / tag.py
1 # -*- coding: utf-8 -*-
2 #Canto-curses - ncurses RSS reader
3 #   Copyright (C) 2014 Jack Miller <jack@codezen.org>
4 #
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.
8
9 from canto_next.hooks import call_hook, on_hook, unhook_all
10 from canto_next.plugins import Plugin, PluginHandler
11 from canto_next.rwlock import read_lock
12
13 from .locks import sync_lock, config_lock
14 from .parser import try_parse, try_eval, prep_for_display
15 from .theme import FakePad, WrapPad, theme_print, theme_reset, theme_border
16 from .config import config, DEFAULT_TAG_FSTRING
17 from .story import Story
18
19 import traceback
20 import logging
21 import curses
22
23 log = logging.getLogger("TAG")
24
25 # TagCore provides the core tag functionality of keeping track of a list of IDs.
26
27 # The Tag class manages stories. Externally, it looks like a Tag takes IDs from
28 # the backend and renders an ncurses pad. No class other than Tag actually
29 # touches Story objects directly.
30
31 class TagPlugin(Plugin):
32     pass
33
34 alltags = []
35
36 class Tag(PluginHandler, list):
37     def __init__(self, tagcore, callbacks):
38         list.__init__(self)
39         PluginHandler.__init__(self)
40
41         self.tagcore = tagcore
42         self.tag = tagcore.tag
43         self.is_tag = True
44         self.updates_pending = 0
45
46         self.pad = None
47         self.footpad = None
48
49         # Note that Tag() is only given the top-level CantoCursesGui
50         # callbacks as it shouldn't be doing input / refreshing
51         # itself.
52
53         self.callbacks = callbacks.copy()
54
55         # Modify our own callbacks so that *_tag_opt assumes
56         # the current tag.
57
58         self.callbacks["get_tag_opt"] =\
59                 lambda x : callbacks["get_tag_opt"](self.tag, x)
60         self.callbacks["set_tag_opt"] =\
61                 lambda x, y : callbacks["set_tag_opt"](self.tag, x, y)
62         self.callbacks["get_tag_name"] = lambda : self.tag
63
64         # This could be implemented as a generic, top-level hook but then N
65         # tags would have access to story objects they shouldn't have and
66         # would have to check every items membership in self, which would be
67         # pointless and time-consuming.
68
69         self.callbacks["item_state_change"] =\
70                 self.on_item_state_change
71
72         # Are there changes pending?
73         self.changed = True
74
75         self.selected = False
76         self.marked = False
77
78         # Information from last refresh
79         self.footlines = 0
80         self.extra_lines = 0
81         self.width = 0
82
83         self.collapsed = False
84         self.border = False
85         self.enumerated = False
86         self.abs_enumerated = False
87
88         # Formats for plugins to override
89         self.pre_format = ""
90         self.post_format = ""
91
92         # Global indices (for enumeration)
93         self.item_offset = 0
94         self.visible_tag_offset = 0
95         self.tag_offset = 0
96         self.sel_offset = 0
97
98         on_hook("curses_opt_change", self.on_opt_change, self)
99         on_hook("curses_tag_opt_change", self.on_tag_opt_change, self)
100         on_hook("curses_attributes", self.on_attributes, self)
101         on_hook("curses_items_added", self.on_items_added, self)
102
103         # Upon creation, this Tag adds itself to the
104         # list of all tags.
105
106         alltags.append(self)
107
108         self.sync(True)
109
110         self.plugin_class = TagPlugin
111         self.update_plugin_lookups()
112
113     def die(self):
114         log.debug("tag %s die()" % self.tag)
115         # Reset so items get die() called and everything
116         # else is notified about items disappearing.
117
118         for s in self:
119             s.die()
120         del self[:]
121
122         alltags.remove(self)
123
124         unhook_all(self)
125
126     def on_item_state_change(self, item):
127         self.need_redraw()
128
129     def on_opt_change(self, opts):
130         if "taglist" in opts and\
131                 ("tags_enumerated" in opts["taglist"] or\
132                 "tags_enumerated_absolute" in opts["taglist"] or\
133                 "border" in opts["taglist"]):
134             self.need_redraw()
135
136         if "tagobj" in opts:
137             self.need_redraw()
138
139     def on_tag_opt_change(self, opts):
140         if self.tag in list(opts.keys()):
141             tc = opts[self.tag]
142             if "collapsed" in tc:
143                 self.need_refresh()
144             else:
145                 self.need_redraw()
146
147     # Technically, we might want to hold sync_lock so that self[:] doesn't
148     # change, but if we're syncing, the setting of needs_redraw isn't important
149     # anymore, and if we're not, there's no issue.
150
151     def on_attributes(self, attributes):
152         for s in self:
153             if s.id in attributes:
154                 self.need_redraw()
155                 break
156
157     def on_items_added(self, tagcore, added):
158         cur_ids = self.get_ids()
159         if tagcore == self.tagcore:
160             for story_id in added:
161                 if story_id not in cur_ids:
162                     self.updates_pending += 1
163             self.need_redraw()
164
165     # We override eq so that empty tags don't evaluate
166     # as equal and screw up things like enumeration.
167
168     def __eq__(self, other):
169         if other and (not other.is_tag or self.tag != other.tag):
170             return False
171         return list.__eq__(self, other)
172
173     def __str__(self):
174         return "%s" % self.tag[self.tag.index(':') + 1:]
175
176     def get_id(self, id):
177         for item in self:
178             if item.id == id:
179                 return item
180
181     def get_ids(self):
182         return [ s.id for s in self ]
183
184     # Inform the tag of global index of it's first item.
185     def set_item_offset(self, offset):
186         if self.item_offset != offset:
187             self.item_offset = offset
188             for i, item in enumerate(self):
189                 item.set_offset(offset + i)
190
191     # Note that this cannot be short-cut (i.e.
192     # copout if sel_offset is already equal)
193     # because it's possible that it's the same
194     # without the items having ever been updated.
195
196     # Alternatively, we could reset them in
197     # on_tag_opt_change, but since the sel
198     # offset does not cause a redraw, there's
199     # no point.
200
201     def set_sel_offset(self, offset):
202         self.sel_offset = offset
203
204         if not self.callbacks["get_tag_opt"]("collapsed"):
205             for i, item in enumerate(self):
206                 item.set_sel_offset(offset + i)
207
208     def set_visible_tag_offset(self, offset):
209         if self.visible_tag_offset != offset:
210             self.visible_tag_offset = offset
211             self.need_redraw()
212
213     def set_tag_offset(self, offset):
214         if self.tag_offset != offset:
215             self.tag_offset = offset
216             self.need_redraw()
217
218     def select(self):
219         if not self.selected:
220             self.selected = True
221             self.need_redraw()
222
223     def unselect(self):
224         if self.selected:
225             self.selected = False
226             self.need_redraw()
227
228     def need_refresh(self):
229         self.changed = True
230         self.callbacks["set_var"]("needs_refresh", True)
231
232     def need_redraw(self):
233         self.changed = True
234         self.callbacks["set_var"]("needs_redraw", True)
235
236     def lines(self, width):
237         if width == self.width and not self.changed:
238             return self.lns
239
240         tag_conf = self.callbacks["get_opt"]("tagobj")
241         taglist_conf = self.callbacks["get_opt"]("taglist")
242
243         # Values to pass on to render
244         self.collapsed = self.callbacks["get_tag_opt"]("collapsed")
245         self.border = taglist_conf["border"]
246         self.enumerated = taglist_conf["tags_enumerated"]
247         self.abs_enumerated = taglist_conf["tags_enumerated_absolute"]
248
249         # Make sure to strip out the category from category:name
250         tag = self.tag.split(':', 1)[1]
251
252         unread = len([s for s in self\
253                 if "canto-state" not in s.content or\
254                 "read" not in s.content["canto-state"]])
255
256         extra_tags = self.callbacks["get_tag_conf"](self.tag)['extra_tags']
257
258         # These are escapes that are handled in the theme_print
259         # lower in the function and should remain present after
260         # evaluation.
261
262         passthru = {}
263         for c in "RrDdUuBbSs012345678[":
264             passthru[c] = "%" + c
265
266         for attr in [ "selected", "unselected", "selected_end", "unselected_end" ]:
267             passthru[attr] = tag_conf[attr]
268
269         passthru['pre'] = self.pre_format
270         passthru['post'] = self.post_format
271
272         parsed = try_parse(tag_conf["format"], DEFAULT_TAG_FSTRING)
273         parsed_pre = try_parse(self.pre_format, "")
274         parsed_post = try_parse(self.post_format, "")
275
276         values = {  'c' : self.collapsed,
277                     't' : tag,
278                     'sel' : self.selected,
279                     'n' : unread,
280                     "extra_tags" : extra_tags,
281                     'tag' : self,
282                     'pending' : self.updates_pending,
283                     'prep' : prep_for_display}
284
285         # Prep all text values for display.
286
287         for value in list(values.keys()):
288             if type(values[value]) in [str, str]:
289                 values[value] = prep_for_display(values[value])
290
291         values.update(passthru)
292
293         values["pre"] = try_eval(parsed_pre, values, "")
294         values["post"] = try_eval(parsed_post, values, "")
295         self.evald_string = try_eval(parsed, values, DEFAULT_TAG_FSTRING)
296
297         self.pad = None
298         self.footpad = None
299         self.width = width
300         self.changed = False
301
302         self.lns = self.render_header(width, FakePad(width))
303         self.footlines = self.render_footer(width, FakePad(width))
304
305         return self.lns
306
307     def pads(self, width):
308         if self.pad and (self.footpad or not self.footlines) and not self.changed:
309             return self.lns
310
311         self.pad = curses.newpad(self.lines(width), width)
312         self.render_header(width, WrapPad(self.pad))
313
314         if self.footlines:
315             self.footpad = curses.newpad(self.footlines, width)
316             self.render_footer(width, WrapPad(self.footpad))
317         return self.lns
318
319     def render_header(self, width, pad):
320         s = self.evald_string
321         lines = 0
322
323         try:
324             while s:
325                 s = theme_print(pad, s, width, "", "")
326
327                 if lines == 0:
328                     header = ""
329                     if self.enumerated:
330                         header += "%1[" + str(self.visible_tag_offset) + "]%0"
331                     if self.abs_enumerated:
332                         header += "%1[" + str(self.tag_offset) + "]%0"
333                     if header:
334                         pad.move(0, 0)
335                         theme_print(pad, header, width, "", "", False, False)
336                         try:
337                             pad.move(1, 0)
338                         except:
339                             pass
340                 lines += 1
341
342             if not self.collapsed and self.border:
343                 theme_print(pad, theme_border("ts") * (width - 2), width,\
344                         "%B"+ theme_border("tl"), theme_border("tr") + "%b")
345                 lines += 1
346         except Exception as e:
347             tb = traceback.format_exc()
348             log.debug("Tag exception:")
349             log.debug("\n" + "".join(tb))
350
351         theme_reset()
352
353         return lines
354
355     def render_footer(self, width, pad):
356         if not self.collapsed and self.border:
357             theme_print(pad, theme_border("bs") * (width - 2), width,\
358                     "%B" + theme_border("bl"), theme_border("br") + "%b")
359             theme_reset()
360             return 1
361         return 0
362
363     # Synchronize this Tag with its TagCore
364
365     def sync(self, force=False):
366         if force or self.tagcore.changes:
367             current_stories = []
368             added_stories = []
369
370             sel = self.callbacks["get_var"]("selected")
371
372             self.tagcore.lock.acquire_read()
373
374             self.tagcore.ack_changes()
375
376             for story in self:
377                 if story.id in self.tagcore:
378                     current_stories.append((self.tagcore.index(story.id), story))
379                 elif story == sel:
380
381                     # If we preserve the selection in an "undead" state, then
382                     # we keep set tagcore changed so that the next sync operation
383                     # will re-evaluate it.
384
385                     self.tagcore.changed()
386
387                     if current_stories:
388                         place = max([ x[0] for x in current_stories ]) + .5
389                     else:
390                         place = -1
391                     current_stories.append((place, story))
392
393             for place, id in enumerate(self.tagcore):
394                 if id not in [ x[1].id for x in current_stories ]:
395                     s = Story(self, id, self.callbacks)
396                     current_stories.append((place, s))
397                     added_stories.append(s)
398
399             self.tagcore.lock.release_read()
400
401             call_hook("curses_stories_added", [ self, added_stories ])
402
403             conf = config.get_conf()
404             if conf["update"]["style"] == "maintain" or self.tagcore.was_reset:
405                 self.tagcore.was_reset = False
406                 current_stories.sort()
407
408             current_stories = [ x[1] for x in current_stories ]
409
410             deleted = []
411
412             for story in self:
413                 if not story in current_stories:
414                     deleted.append(story)
415                     story.die()
416
417             # Properly dispose of the remaining stories
418
419             call_hook("curses_stories_removed", [ self, deleted ])
420
421             del self[:]
422             self.extend(current_stories)
423
424             # Trigger a refresh so that classes above (i.e. TagList) will remap
425             # items
426
427             self.need_refresh()
428
429         # Pass the sync onto story objects
430         for s in self:
431             s.sync()
432
433         self.updates_pending = 0