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