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