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