Threading fixes #1
[canto-curses.git] / canto_curses / tag.py
1 # -*- coding: utf-8 -*-
2 #Canto-curses - ncurses RSS reader
3 #   Copyright (C) 2010 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         self.reset()
98         remove_hook("curses_opt_change", self.on_opt_change)
99         remove_hook("curses_tag_opt_change", self.on_tag_opt_change)
100         remove_hook("curses_attributes", self.on_attributes)
101
102     def on_item_state_change(self, item):
103         self.need_redraw()
104
105     def on_opt_change(self, opts):
106         if "taglist" in opts and\
107                 ("tags_enumerated" in opts["taglist"] or\
108                 "tags_enumerated_absolute" in opts["taglist"] or\
109                 "border" in opts["taglist"]):
110             self.need_redraw()
111
112         if "tag" in opts:
113             self.need_redraw()
114
115     def on_tag_opt_change(self, opts):
116         if self.tag in list(opts.keys()):
117             tc = opts[self.tag]
118             if "collapsed" in tc:
119                 self.need_refresh()
120             else:
121                 self.need_redraw()
122
123     def on_attributes(self, attributes):
124         for s in self:
125             if s.id in attributes:
126                 self.need_redraw()
127                 break
128
129     # We override eq so that empty tags don't evaluate
130     # as equal and screw up things like enumeration.
131
132     def __eq__(self, other):
133         if not hasattr(other, "tag") or self.tag != other.tag:
134             return False
135         return list.__eq__(self, other)
136
137     def __str__(self):
138         return "tag: %s" % self.tag
139
140     def get_id(self, id):
141         for item in self:
142             if item.id == id:
143                 return item
144         return None
145
146     def get_ids(self):
147         return [ s.id for s in self ]
148
149     # Take a list of ordered ids and reorder ourselves, without generating any
150     # unnecessary add/remove hooks.
151
152     def reorder(self, ids):
153         cur_stories = [ s for s in self ]
154
155         # Perform the actual reorder.
156         stories = [ self.get_id(id) for id in ids ]
157
158         del self[:]
159         list.extend(self, stories)
160
161         # Deal with items that aren't listed. Usually this happens if the item
162         # would be filtered, but is protected for some reason (like selection)
163
164         # NOTE: This is bad behavior, but if we don't retain these items, other
165         # code will crap-out expecting this item to exist. Built-in transforms
166         # are hardened to never discard items with the filter-immune reason,
167         # like selection, so this is just for bad user transforms.
168
169         for s in cur_stories:
170             if s not in self:
171                 log.warn("Warning: A filter is filtering filter-immune items.")
172                 log.warn("Compensating. This may cause items to jump unexpectedly.")
173                 list.append(self, s)
174
175         log.debug("Self: %s" % [ s for s in self ])
176
177         # Handle updating story information.
178         for i, story in enumerate(self):
179             story.set_rel_offset(i)
180             story.set_offset(self.item_offset + i)
181             story.set_sel_offset(self.sel_offset + i)
182
183         # Request redraw to update item counts.
184         self.need_redraw()
185
186     # Inform the tag of global index of it's first item.
187     def set_item_offset(self, offset):
188         if self.item_offset != offset:
189             self.item_offset = offset
190             for i, item in enumerate(self):
191                 item.set_offset(offset + i)
192
193     # Note that this cannot be short-cut (i.e.
194     # copout if sel_offset is already equal)
195     # because it's possible that it's the same
196     # without the items having ever been updated.
197
198     # Alternatively, we could reset them in
199     # on_tag_opt_change, but since the sel
200     # offset does not cause a redraw, there's
201     # no point.
202
203     def set_sel_offset(self, offset):
204         self.sel_offset = offset
205
206         if not self.callbacks["get_tag_opt"]("collapsed"):
207             for i, item in enumerate(self):
208                 item.set_sel_offset(offset + i)
209
210     def set_visible_tag_offset(self, offset):
211         if self.visible_tag_offset != offset:
212             self.visible_tag_offset = offset
213             self.need_redraw()
214
215     def set_tag_offset(self, offset):
216         if self.tag_offset != offset:
217             self.tag_offset = offset
218             self.need_redraw()
219
220     def select(self):
221         if not self.selected:
222             self.selected = True
223             self.need_redraw()
224
225     def unselect(self):
226         if self.selected:
227             self.selected = False
228             self.need_redraw()
229
230     def need_refresh(self):
231         self.changed = True
232         self.callbacks["set_var"]("needs_refresh", True)
233
234     def need_redraw(self):
235         self.changed = True
236         self.callbacks["set_var"]("needs_redraw", True)
237
238     def do_changes(self, width):
239         if width != self.width or self.changed:
240             self.refresh(width)
241
242     def refresh(self, width):
243         self.width = width
244
245         lines = self.render_header(width, FakePad(width))
246
247         self.pad = curses.newpad(lines, width)
248         self.render_header(width, WrapPad(self.pad))
249
250         self.lines = lines
251
252         lines = self.render_footer(width, FakePad(width))
253
254         if lines:
255             self.footpad = curses.newpad(lines, width)
256             self.render_footer(width, WrapPad(self.footpad))
257         else:
258             self.footpad = None
259
260         self.footlines = lines
261
262         self.changed = False
263
264     def render_header(self, width, pad):
265         tag_conf = self.callbacks["get_opt"]("tag")
266         taglist_conf = self.callbacks["get_opt"]("taglist")
267         collapsed = self.callbacks["get_tag_opt"]("collapsed")
268
269         # Make sure to strip out the category from category:name
270         tag = self.tag.split(':', 1)[1]
271
272         unread = len([s for s in self\
273                 if "canto-state" not in s.content or\
274                 "read" not in s.content["canto-state"]])
275
276         extra_tags = self.callbacks["get_tag_conf"](self.tag)['extra_tags']
277
278         # These are escapes that are handled in the theme_print
279         # lower in the function and should remain present after
280         # evaluation.
281
282         passthru = {}
283         for c in "RrDdUuBbSs012345678":
284             passthru[c] = "%" + c
285
286         for attr in [ "selected", "unselected", "selected_end", "unselected_end" ]:
287             passthru[attr] = tag_conf[attr]
288
289         fstring = tag_conf["format"]
290         try:
291             parsed = parse_conditionals(fstring)
292         except Exception as e:
293             log.warn("Failed to parse conditionals in fstring: %s" %
294                     fstring)
295             log.warn("\n" + "".join(traceback.format_exc()))
296             log.warn("Falling back to default.")
297             parsed = parse_conditionals(DEFAULT_TAG_FSTRING)
298
299         values = { 'en' : taglist_conf["tags_enumerated"],
300                     'aen' : taglist_conf["tags_enumerated_absolute"],
301                     'c' : collapsed,
302                     't' : tag,
303                     'sel' : self.selected,
304                     'n' : unread,
305                     'to' : self.tag_offset,
306                     'vto' : self.visible_tag_offset,
307                     "extra_tags" : extra_tags,
308                     'tag' : self,
309                     'prep' : prep_for_display}
310
311         # Prep all text values for display.
312
313         for value in list(values.keys()):
314             if type(values[value]) in [str, str]:
315                 values[value] = prep_for_display(values[value])
316
317         values.update(passthru)
318
319         try:
320             s = eval_theme_string(parsed, values)
321         except Exception as e:
322             log.warn("Failed to evaluate fstring: %s" % fstring)
323             log.warn("\n" + "".join(traceback.format_exc()))
324             log.warn("Falling back to default")
325
326             parsed = parse_conditionals(DEFAULT_TAG_FSTRING)
327             s = eval_theme_string(parsed, values)
328
329         lines = 0
330
331         while s:
332             s = theme_print(pad, s, width, "", "")
333             lines += 1
334
335         if not collapsed and taglist_conf["border"]:
336             theme_print(pad, theme_border("ts") * (width - 2), width,\
337                     "%B%1"+ theme_border("tl"), theme_border("tr") + "%0%b")
338             lines += 1
339
340         theme_reset()
341
342         return lines
343
344     def render_footer(self, width, pad):
345         taglist_conf = self.callbacks["get_opt"]("taglist")
346         collapsed = self.callbacks["get_tag_opt"]("collapsed")
347
348         if not collapsed and taglist_conf["border"]:
349             theme_print(pad, theme_border("bs") * (width - 2), width,\
350                     "%B%1" + theme_border("bl"), theme_border("br") + "%0%b")
351             theme_reset()
352             return 1
353         return 0
354
355     # Synchronize this Tag with its TagCore
356
357     @read_lock(sync_lock)
358     def sync(self, force=False):
359         if force or self.tagcore.changed:
360             my_ids = [ s.id for s in self ]
361             new_stories = []
362
363             self.tagcore.lock.acquire_read()
364
365             for id in self.tagcore:
366                 if id in my_ids:
367                     s = self[my_ids.index(id)]
368                     new_stories.append(s)
369                     self.remove(s)
370                     my_ids.remove(s.id)
371                 else:
372                     new_stories.append(Story(id, self.callbacks))
373
374             self.tagcore.lock.release_read()
375
376             # Properly dispose of the remaining stories
377             for s in self:
378                 s.die()
379             del self[:]
380
381             self.extend(new_stories)
382
383         # Pass the sync onto story objects
384         for s in self:
385             s.sync()