%C escape suspends colors as well as attributes
[canto-curses.git] / canto_curses / story.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.plugins import Plugin, PluginHandler
10 from canto_next.hooks import on_hook, unhook_all
11
12 from .theme import FakePad, WrapPad, theme_print, theme_len, theme_reset, theme_border
13 from .parser import try_parse, try_eval, prep_for_display
14 from .config import DEFAULT_FSTRING
15 from .tagcore import tag_updater
16
17 import traceback
18 import logging
19 import curses
20
21 log = logging.getLogger("STORY")
22
23 class StoryPlugin(Plugin):
24     pass
25
26 # The Story class is the basic wrapper for an item to be displayed. It manages
27 # its own state only because it affects its representation, it's up to a higher
28 # class to actually communicate state changes to the backend.
29
30 class Story(PluginHandler):
31     def __init__(self, tag, id, callbacks):
32         PluginHandler.__init__(self)
33
34         self.callbacks = callbacks
35
36         self.parent_tag = tag
37         self.is_tag = False
38         self.id = id
39         self.pad = None
40
41         self.selected = False
42         self.marked = False
43
44         # Are there changes pending?
45         self.changed = True
46
47         self.fresh_state = False
48         self.fresh_tags = False
49
50         self.width = 0
51
52         # This is used by the rendering code.
53         self.extra_lines = 0
54
55         # Pre and post formats, to be used by plugins
56         self.pre_format = ""
57         self.post_format = ""
58
59         # Offset globally and in-tag.
60         self.offset = 0
61         self.rel_offset = 0
62         self.enumerated = False
63         self.rel_enumerated = False
64
65         # This should exist before the hook is setup, or the hook will fail.
66         self.content = {}
67
68         on_hook("curses_opt_change", self.on_opt_change, self)
69         on_hook("curses_tag_opt_change", self.on_tag_opt_change, self)
70         on_hook("curses_attributes", self.on_attributes, self)
71
72         # Grab initial content, if any, the rest will be handled by the
73         # attributes hook
74
75         self.content = tag_updater.get_attributes(self.id)
76         self.new_content = None
77
78         self.plugin_class = StoryPlugin
79         self.update_plugin_lookups()
80
81     def die(self):
82         self.parent_tag = None
83         unhook_all(self)
84
85     def __eq__(self, other):
86         if not other:
87             return False
88         if not hasattr(other, "id"):
89             return False
90         return self.id == other.id
91
92     def __str__(self):
93         return "story: %s" % self.id
94
95     # On_attributes updates new_content. We don't lock because we don't
96     # particularly care what version of new_content the next sync() call gets.
97
98     def on_attributes(self, attributes):
99         if self.id in attributes:
100             new_content = attributes[self.id]
101
102             if not (new_content is self.content):
103                 self.new_content = new_content
104
105     def sync(self):
106         if self.new_content == None:
107             return
108
109         old_content = self.content
110         self.content = self.new_content
111         self.new_content = None
112
113         if 'canto-state' in old_content and self.fresh_state:
114             self.content['canto-state'] = old_content['canto-state']
115             self.fresh_state = False
116
117         if 'canto-tags' in old_content and self.fresh_tags:
118             self.content['canto-tags'] = old_content['canto-tags']
119             self.fresh_tags = False
120
121         self.need_redraw()
122
123     def on_opt_change(self, config):
124         if "taglist" in config and "border" in config["taglist"]:
125             self.need_redraw()
126
127         if "story" not in config:
128             return
129
130         if "format_attrs" in config["story"]:
131             needed_attrs = []
132             for attr in config["story"]["format_attrs"]:
133                 if attr not in self.content:
134                     needed_attrs.append(attr)
135             if needed_attrs:
136                 log.debug("%s needs: %s" % (self.id, needed_attrs))
137                 tag_updater.need_attributes(self.id, needed_attrs)
138
139         # All other story options are formats / enumerations, redraw.
140
141         self.need_redraw()
142
143     def on_tag_opt_change(self, config):
144         tagname = self.callbacks["get_tag_name"]()
145         if tagname in config and "enumerated" in config[tagname]:
146             self.need_redraw()
147
148     # Add / remove state. Return True if an actual change, False otherwise.
149
150     def _handle_key(self, attr, key):
151         if key not in self.content or self.content[key] == "":
152             self.content[key] = []
153
154         # Negative attribute
155         if attr[0] == "-":
156             attr = attr[1:]
157             if attr == "marked":
158                 return self.unmark()
159             elif attr in self.content[key]:
160                 self.content[key].remove(attr)
161                 self.need_redraw()
162                 return True
163
164         # Toggle attribute
165         elif attr[0] == '%':
166             attr = attr[1:]
167             if attr == "marked":
168                 if self.marked:
169                     self.unmark()
170                 else:
171                     self.mark()
172             else:
173                 if attr in self.content[key]:
174                     self.content[key].remove(attr)
175                 else:
176                     self.content[key].append(attr)
177                 self.need_redraw()
178                 return True
179
180         # Positive attribute
181         else:
182             if attr == "marked":
183                 return self.mark()
184             elif attr not in self.content[key]:
185                 self.content[key].append(attr)
186                 self.need_redraw()
187                 return True
188         return False
189
190     # Simple wrapper to call tag's item_state_change callback on an actual
191     # change.
192
193     def handle_state(self, attr):
194         r = self._handle_key(attr, "canto-state")
195         if r:
196             self.fresh_state = True
197             self.callbacks["item_state_change"](self)
198         return r
199
200     def handle_tag(self, tag):
201         r = self._handle_key(tag, "canto-tags")
202         if r:
203             self.fresh_tags = True
204             self.callbacks["item_state_change"](self)
205         return r
206
207     def select(self):
208         if not self.selected:
209             self.selected = True
210             self.need_redraw()
211
212     def unselect(self):
213         if self.selected:
214             self.selected = False
215             self.need_redraw()
216
217     def mark(self):
218         if not self.marked:
219             self.marked = True
220             self.need_redraw()
221             return True
222         return False
223
224     def unmark(self):
225         if self.marked:
226             self.marked = False
227             self.need_redraw()
228             return True
229         return False
230
231     def set_offset(self, offset):
232         if self.offset != offset:
233             self.offset = offset
234             self.need_redraw()
235
236     def set_rel_offset(self, offset):
237         if self.rel_offset != offset:
238             self.rel_offset = offset
239             self.need_redraw()
240
241     # This is not useful in the interface,
242     # so no redraw required on it.
243
244     def set_sel_offset(self, offset):
245         self.sel_offset = offset
246
247     def need_redraw(self):
248         self.changed = True
249         self.callbacks["set_var"]("needs_redraw", True)
250
251     def need_refresh(self):
252         self.changed = True
253         self.callbacks["set_var"]("needs_refresh", True)
254
255     def lines(self, width):
256         if width == self.width and not self.changed:
257             return self.lns
258
259         # Make sure we actually have all of the attributes needed
260         # to complete the render.
261
262         story_conf = self.callbacks["get_opt"]("story")
263
264         self.enumerated = story_conf["enumerated"]
265         self.rel_enumerated = self.callbacks["get_tag_opt"]("enumerated")
266
267         for attr in story_conf["format_attrs"]:
268             if attr not in self.content:
269
270                 # Not having needed info is a good reason to
271                 # sync.
272
273                 self.sync()
274                 self.need_refresh()
275                 self.callbacks["release_gui"]()
276
277                 log.debug("%s still needs %s" % (self, attr))
278
279                 self.left = " "
280                 self.left_more = " "
281                 self.right = " "
282
283                 self.evald_string = "Waiting on content..."
284
285                 self.lns = 1
286                 return self.lns
287
288         parsed = try_parse(story_conf["format"], DEFAULT_FSTRING)
289         parsed_pre = try_parse(self.pre_format, "")
290         parsed_post = try_parse(self.post_format, "")
291
292         # These are escapes that are handled in the theme_print
293         # lower in the function and should remain present after
294         # evaluation.
295
296         passthru = {}
297         for c in "RrDdUuBbSs012345678[":
298             passthru[c] = "%" + c
299
300         # Add refactored themability variables:
301
302         for attr in [ "selected", "read", "marked" ]:
303             passthru[attr] = story_conf[attr]
304             passthru["un" + attr] = story_conf["un" + attr]
305             passthru[attr + "_end"] = story_conf[attr + "_end"]
306             passthru["un" + attr + "_end"] = story_conf["un" + attr + "_end"]
307
308         values = { 'sel' : self.selected,
309                     'm' : self.marked,
310                    'rd' : "read" in self.content["canto-state"],
311                    'ut' : self.content["canto-tags"],
312                     't' : self.content["title"],
313                     'l' : self.content["link"],
314                  'item' : self,
315                  'prep' : prep_for_display}
316
317         # Prep all text values for display.
318
319         for value in list(values.keys()):
320             if type(values[value]) == str:
321                 values[value] = prep_for_display(values[value])
322
323         values.update(passthru)
324
325         values["pre"] = try_eval(parsed_pre, values, "")
326         values["post"] = try_eval(parsed_post, values, "")
327         self.evald_string = try_eval(parsed, values, DEFAULT_FSTRING)
328
329         taglist_conf = self.callbacks["get_opt"]("taglist")
330
331         if taglist_conf["border"]:
332             self.left = "%C%B" + theme_border("ls") + "%b %c"
333             self.left_more = "%C%B" + theme_border("ls") + "%b     %c"
334             self.right = "%C %B" + theme_border("rs") + "%b%c"
335         else:
336             self.left = "%C %c"
337             self.left_more = "%C     %c"
338             self.right = "%C %c"
339
340         self.pad = None
341         self.width = width
342         self.changed = False
343
344         self.lns = self.render(FakePad(width), width)
345         return self.lns
346
347     def pads(self, width):
348         if self.pad and not self.changed:
349             return self.lns
350
351         self.pad = curses.newpad(self.lines(width), width)
352         self.render(WrapPad(self.pad), width)
353         return self.lns
354
355     def render(self, pad, width):
356         s = self.evald_string
357
358         lines = 0
359
360         try:
361             while s:
362                 # Left border, for first line
363                 if lines == 0:
364                     l = self.left
365
366                 # Left border, for subsequent lines (indent)
367                 else:
368                     l = self.left_more
369
370                 s = theme_print(pad, s, width, l, self.right)
371
372                 # Handle overwriting with offset information
373
374                 if lines == 0:
375                     header = ""
376                     if self.enumerated:
377                         header += "%1[" + str(self.offset) + "]%0"
378                     if self.rel_enumerated:
379                         header += "%1[" + str(self.rel_offset) + "]%0"
380                     if header:
381                         pad.move(0, 0)
382                         theme_print(pad, header, width, "","", False, False)
383                         try:
384                             pad.move(1, 0)
385                         except:
386                             pass
387
388                 lines += 1
389
390         # Render exceptions should be non-fatal. The worst
391         # case scenario is that one story's worth of space
392         # is going to be fucked up.
393
394         except Exception as e:
395             tb = traceback.format_exc()
396             log.debug("Story exception:")
397             log.debug("\n" + "".join(tb))
398
399         # Reset theme counters
400         theme_reset()
401
402         return lines