Convert to JSON internal config
[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
12 from theme import FakePad, WrapPad, theme_print
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 = "%?{sel}(%R:)%?{c}([+]:[-])%?{en}([%{to}]:)%?{aen}([%{vto}]:) %t [%B%2%n%1%b]%?{sel}(%r:)"
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                 ("tag_enumerated" in opts["taglist"] or\
94                 "tags_enumerated_absolute" in opts["taglist"]):
95             self.need_redraw()
96
97     def on_tag_opt_change(self, opts):
98         if self.tag in opts.keys():
99             self.need_redraw()
100
101     # We override eq so that empty tags don't evaluate
102     # as equal and screw up things like enumeration.
103
104     def __eq__(self, other):
105         if not hasattr(other, "tag") or self.tag != other.tag:
106             return False
107         return list.__eq__(self, other)
108
109     # Create Story from ID before appending to list.
110
111     def add_items(self, ids):
112         added = []
113         for id in ids:
114             s = Story(id, self.callbacks)
115             self.append(s)
116             added.append(s)
117
118             rel = len(self) - 1
119             s.set_rel_offset(rel)
120             s.set_offset(self.item_offset + rel)
121             s.set_sel_offset(self.sel_offset + rel)
122
123         call_hook("items_added", [ self, added ] )
124
125     # Take a list of ordered ids and reorder ourselves, without generating any
126     # unnecessary add/remove hooks.
127
128     def reorder(self, ids):
129         cur_stories = [ s for s in self ]
130
131         # Perform the actual reorder.
132         stories = [ self.get_id(id) for id in ids ]
133
134         del self[:]
135         list.extend(self, stories)
136
137         # Deal with items that aren't listed. Usually this happens if the item
138         # would be filtered, but is protected for some reason (like selection)
139
140         # NOTE: This is bad behavior, but if we don't retain these items, other
141         # code will crap-out expecting this item to exist. Built-in transforms
142         # are hardened to never discard items with the filter-immune reason,
143         # like selection, so this is just for bad user transforms.
144
145         for s in cur_stories:
146             if s not in self:
147                 log.warn("Warning: A filter is filtering filter-immune items.")
148                 log.warn("Compensating. This may cause items to jump unexpectedly.")
149                 list.append(self, s)
150
151         log.debug("Self: %s" % [ s for s in self ])
152
153         # Handle updating story information.
154         for i, story in enumerate(self):
155             story.set_rel_offset(i)
156             story.set_offset(self.item_offset + i)
157             story.set_sel_offset(self.sel_offset + i)
158
159     # Remove Story based on ID
160
161     def remove_items(self, ids):
162         removed = []
163
164         # Copy self so we can remove from self
165         # without screwing up iteration.
166
167         for idx, item in enumerate(self[:]):
168             if item.id in ids:
169                 log.debug("removing: %s" % (item.id,))
170
171                 list.remove(self, item)
172                 item.die()
173                 removed.append(item)
174
175         # Update indices of items.
176         for i, story in enumerate(self):
177             story.set_rel_offset(i)
178             story.set_offset(self.item_offset + i)
179             story.set_sel_offset(self.sel_offset + i)
180
181         call_hook("items_removed", [ self, removed ] )
182
183     # Remove all stories from this tag.
184
185     def reset(self):
186         for item in self:
187             item.die()
188
189         call_hook("items_removed", [ self, self[:] ])
190         del self[:]
191
192     def get_id(self, id):
193         for item in self:
194             if item.id == id:
195                 return item
196         return None
197
198     def get_ids(self):
199         return [ s.id for s in self ]
200
201     # Inform the tag of global index of it's first item.
202     def set_item_offset(self, offset):
203         if self.item_offset != offset:
204             self.item_offset = offset
205             for i, item in enumerate(self):
206                 item.set_offset(offset + i)
207
208     # Note that this cannot be short-cut (i.e.
209     # copout if sel_offset is already equal)
210     # because it's possible that it's the same
211     # without the items having ever been updated.
212
213     # Alternatively, we could reset them in
214     # on_tag_opt_change, but since the sel
215     # offset does not cause a redraw, there's
216     # no point.
217
218     def set_sel_offset(self, offset):
219         self.sel_offset = offset
220
221         if not self.callbacks["get_tag_opt"]("['collapsed']"):
222             for i, item in enumerate(self):
223                 item.set_sel_offset(offset + i)
224
225     def set_visible_tag_offset(self, offset):
226         if self.visible_tag_offset != offset:
227             self.visible_tag_offset = offset
228             self.need_redraw()
229
230     def set_tag_offset(self, offset):
231         if self.tag_offset != offset:
232             self.tag_offset = offset
233             self.need_redraw()
234
235     def select(self):
236         if not self.selected:
237             self.selected = True
238             self.need_redraw()
239
240     def unselect(self):
241         if self.selected:
242             self.selected = False
243             self.need_redraw()
244
245     def need_redraw(self):
246         self.changed = True
247         self.callbacks["set_var"]("needs_redraw", True)
248
249     def do_changes(self, width):
250         if width != self.width or self.changed:
251             self.refresh(width)
252
253     def refresh(self, width):
254         self.width = width
255
256         lines = self.render_header(width, FakePad(width))
257
258         self.pad = curses.newpad(lines, width)
259         self.render_header(width, WrapPad(self.pad))
260
261         self.lines = lines
262         self.changed = False
263
264     def render_header(self, width, pad):
265         fstring = self.callbacks["get_opt"]("['tag']['format']")
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         # These are escapes that are handled in the theme_print
277         # lower in the function and should remain present after
278         # evaluation.
279
280         passthru = {}
281         for c in "RrDdUuBbSs012345678":
282             passthru[c] = "%" + c
283
284         try:
285             parsed = parse_conditionals(fstring)
286         except Exception, e:
287             log.warn("Failed to parse conditionals in fstring: %s" %
288                     fstring)
289             log.warn("\n" + "".join(traceback.format_exc(e)))
290             log.warn("Falling back to default.")
291             parsed = parse_conditionals(DEFAULT_TAG_FSTRING)
292
293         values = { 'en' : taglist_conf["tags_enumerated"],
294                     'aen' : taglist_conf["tags_enumerated_absolute"],
295                     'c' : collapsed,
296                     't' : tag,
297                     'sel' : self.selected,
298                     'n' : unread,
299                     'to' : self.tag_offset,
300                     'vto' : self.visible_tag_offset,
301                     'tag' : self}
302
303         values.update(passthru)
304
305         try:
306             s = eval_theme_string(parsed, values)
307         except Exception, e:
308             log.warn("Failed to evaluate fstring: %s" % fstring)
309             log.warn("\n" + "".join(traceback.format_exc(e)))
310             log.warn("Falling back to default")
311
312             parsed = parse_conditionals(DEFAULT_TAG_FSTRING)
313             s = eval_theme_string(parsed, values)
314
315         s += u"\n"
316
317         lines = 0
318
319         while s:
320             s = theme_print(pad, s, width, u"", u"")
321             lines += 1
322
323         return lines