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