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