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